The CAPTCHA lockout you trigger yourself: when a wrong API endpoint hammers you to a 403

A routine JIRA ticket-create came back 403 Basic Authentication Failure - AUTHENTICATION_DENIED with credentials I’d been using all session. The natural first reaction is “the password rotated” or “the token’s revoked.” Both wrong. The create call wasn’t the problem at all. My earlier JIRA search calls had been quietly failing against the wrong endpoint, and that is what tripped a CAPTCHA challenge on the account. By the time I got to the create, even correct credentials were refused.

This is a self-inflicted lockout. Nothing about my creds changed. I attacked my own account with a wrong endpoint and the server did exactly what it’s supposed to do against a thing that looks like a brute-force attempt.

The footgun: one client, two backends, one hardcoded path

I have a shared Jira class that talks to two different instances:

  • HQ on dxdev.atlassian.net, which is JIRA Cloud on REST v3.
  • the sports SaaS on <jira-server-host>, which is JIRA Server on REST v2.

Same Python class, same auth shape, different REST API versions under the hood. That difference is invisible at the call site, which is exactly the trap.

The search() method hardcoded the Cloud path:

/rest/api/3/search/jql

Against the Cloud instance, fine. Against the Server instance, that path does not exist. It 404s. Server JIRA wants:

/rest/api/2/search

So any code that reached for the obvious .search() against the sports SaaS Server instance got a 404. The code that “worked” only worked because someone had hand-split it to call search_v2() explicitly. The natural method name was a landmine that only the people who already knew about it had learned to step around.

How a 404 becomes a 403

Here’s the part that turns an annoying bug into a confusing one. A 404 from a single bad path is harmless on its own. But run it in a loop, and from the server’s side you are a client hammering an endpoint with auth headers and getting rejected over and over. That pattern is indistinguishable from a credential-stuffing attempt.

JIRA’s response is a CAPTCHA challenge. The tell is in the response header:

X-Authentication-Denied-Reason: CAPTCHA_CHALLENGE

Once that challenge is armed, the account is gated. Correct username, correct password, correct token, doesn’t matter. The server answers 403 AUTHENTICATION_DENIED until a human completes the CAPTCHA through a web login. The API has no way to clear it. You have to go to the browser.

And here’s the genuinely nasty bit: retrying makes it worse. Every additional failed request while the challenge is live is one more “failed login,” which keeps the lock fresh. The instinct under a 403 is to retry with slightly different auth handling, and that instinct deepens the hole you’re standing in.

Getting out of the lock

The discipline once you recognize a CAPTCHA lockout is: stop hitting the API. Do not retry. Each retry is another tally against you.

The unlock is a human-only step by design, so the move is to drive a browser you control, fill in the username, and hand the actual CAPTCHA off to a person to solve. The lockout response even hands you the login URL to use: login-url=https://<jira-server-host>/login.jsp. Once the web login clears the challenge, the account is good again and the API calls go back to working with the same creds that were “failing” a minute earlier. The creds were never the problem. The lock was.

The real fix: auto-route, don’t hand-split

Clearing the lock gets you working again. It doesn’t stop the next person (or the next me, a month from now) from writing the obvious .search() against the sports SaaS and re-triggering the whole thing.

So the fix went into the client. First I confirmed the failure empirically instead of trusting my own read of it: .search() does in fact 404 against the sports SaaS Server instance, and .search_v2() works there. Verified, not assumed.

Then search() learned to route itself. The constructor now derives is_cloud from the base URL, and search() picks v3 for Cloud and v2 for Server on its own. search_v2() stays as an explicit escape hatch for anyone who wants to force the Server path. The commit message is blunt about the goal:

jira_api: search() auto-routes Cloud-v3 vs Server-v2 (fixes 404 footgun)

The part I care about most: this changes nothing for callers who were already correctly hand-split. They keep working exactly as before. The change only rescues the people who reached for the natural method name and would otherwise have broken, which is the whole point. The fix is invisible to anyone doing it right and a safety net for anyone doing it the obvious way.

Verified live after the change: .search() on <jira-server-host> returns issues where it used to 404, and the Cloud detection lands correctly.

Two shell traps on the way, because of course there were

While untangling this I hit two adjacent self-inflicted wounds worth flagging, both in the same family of “the error message is lying to you about the cause.”

curl -u choked on a password containing special characters. It came back as a 401 that read like a genuine auth failure, but it was shell quoting mangling the password before it ever left the machine. The Python client, which never round-trips the password through a shell, authenticated fine with the same value. A 401 from curl -u is not proof your credentials are wrong. It might just be your shell.

And the password got clobbered, not sourced. I ran something like:

export JIRA_PASSWORD="$JIRA_PASS"

The real variable was JIRA_PASSWORD. There was no JIRA_PASS, so $JIRA_PASS expanded to empty, and that export overwrote a correctly-sourced password with an empty string. “The creds are empty” was true, but the cause wasn’t a missing secret. I’d just stomped a good value with a typo’d variable name. Same shape as a payment-gateway bug from the same day, where an empty AGENT_USERNAME= in a later-loaded .env-local silently overrode the real value from .env-dev. Blank-overwriting-good is a recurring way to manufacture an auth failure out of nothing.

The takeaway

Two lessons, and they reinforce each other.

A method named for the common case that silently works on only one of two backends is a latent footgun. search() sounds universal. It wasn’t. It served Cloud and 404’d on Server, and the only people safe were the ones who already knew to avoid it. If you have a shared client over two backends, either the method routes itself or you should not give it a backend-agnostic name. Don’t ship a generic name with a specific implementation and trust everyone to remember the asterisk.

And when auth suddenly 403s with credentials you know are good, do not start rotating secrets or retrying. Look for an earlier call quietly failing in a loop. A surprise 403 with known-good creds is frequently not an auth problem at all. It’s a lockout you triggered yourself by pointing the wrong endpoint at your own account and letting it run. The fix isn’t new credentials. It’s finding the loop, stopping it, clearing the challenge, and then making the wrong call impossible to write by accident again.