I turned on a captcha for one login screen. Two apps I never touched stopped letting anyone in.
August 6, 2026
Most outages I get called about start with someone deploying code. This one started with a checkbox. The change was scoped to a single login screen, tested on that login screen, and worked perfectly on that login screen — and it still locked people out of two applications whose code I hadn’t opened that day.
Three front doors, one lock
The setup is common enough that you probably have some version of it: one product, one identity backend, several ways in. A web portal for customers, an admin console for staff, and a mobile app — all authenticating against the same hosted auth project.
The task was to put bot protection on the customer portal’s login. That’s three small pieces: render a challenge widget on the sign-in form, pass the resulting token along with the email and password, and turn on the provider’s “enable captcha” setting so the backend actually checks it. I did all three, watched a real login succeed with a real solved challenge, and shipped.
What I had not internalized is that the third piece lives at a different altitude than the first two. The widget is a page. The token is a request. The setting is the whole project.
The error arrived in an app I hadn’t touched
Within minutes, staff couldn’t get into the admin console. The message they got back was “Captcha protection: request disallowed (no captcha_token found).” Then the mobile app started failing the same way.
Both were behaving correctly. The backend now required a captcha token on every login for the entire project, and only one of the three apps had any idea it was supposed to send one. I hadn’t added a feature to one app — I had added a requirement to all of them, and then satisfied it in exactly one place.
That distinction is the whole post. A feature is something you opt into. A requirement is something every existing caller inherits the moment you turn it on, whether or not it knows the rule changed.
Restore first, fix second
Because enforcement is project-wide, there was no per-app exemption to hide behind and no partial rollback — you cannot leave it on for the portal and off for staff. The fastest path back to “people can log in” was a single API call to turn enforcement off again, accepting a short window without bot protection on the portal.
That trade is worth naming out loud, because it’s the kind of decision that looks bad in a postmortem and is almost always right in the moment: a few minutes of the protection you had yesterday beats an hour of everyone locked out today. The security posture didn’t get worse than it was before I started. Availability did.
Then the real work: send a token from every front door, verify each one against the live backend, and only re-enable enforcement once all of them had shipped. The web apps were the easy half — the same widget, the same one-line addition passing a captchaToken through with the sign-in call.
The mobile app was the interesting half
The challenge provider has no native SDK, so a React Native login has to host the web challenge somehow. Two obvious approaches both fail, and they fail in the same misleading way — the widget renders, says “Verifying…”, and spins forever with no error to search for.
- Rendering the challenge from inline HTML inside the WebView spins forever. The provider validates the real web origin of the page hosting the widget, and a string of inline HTML doesn’t have one.
- Overriding the WebView’s User-Agent to look like a mobile browser also spins forever — a custom UA is itself a bot signal, so the session gets quietly treated as automated.
- What works: serve a tiny chrome-less page from a real https origin that the widget already allows, render the challenge there, and post the token back to the app via the WebView message bridge.
- One detail that costs an hour if you miss it: the WebView’s origin whitelist has to permit about: URLs as well as https, because the challenge runs part of itself inside about:blank frames.
- After that, the mobile sign-in call is character-for-character the same as the web one — same token parameter, same shape.
The release build that installed fine and couldn’t reach anything
One more mobile trap from the same day, because it presents as “the captcha work broke the app” and isn’t. In a locally-built release, the framework does not inline the public environment variables the way it does in development — the bundling step runs from a directory where the .env file was never loaded.
So the app installs cleanly, launches, and then can’t reach anything: empty API URL, empty auth keys, empty captcha sitekey. Nothing throws a useful error, because none of those values are wrong, they’re just blank.
The fix is to bake the runtime configuration a release actually needs into the app’s build config at prebuild time and read it from there first, with environment variables as a development-only fallback. Config a shipped binary depends on should not live somewhere only your laptop can see.
What I do differently now
- Before flipping any project-scoped auth setting, I write down every client that authenticates against it — including the ones I’m not working on. The list is usually longer than the mental model.
- Dependent changes get sequenced explicitly: ship the client-side change everywhere, verify each client against the live backend, enforce last. Enforcement is the final step, never the first.
- I look for the same shape elsewhere — anything where turning a flag on adds an obligation rather than a capability. Token requirements, required headers, stricter validation, mandatory fields.
- Two unrelated findings from the same week that rhyme with it: a free-tier backend that sleeps is only a latent problem until something fetches it without a timeout, at which point it hangs every render and every build. Bound every server-side fetch.
- And a redirect guard that checks whether a path starts with a double slash isn’t a guard — the URL parser normalizes backslashes to forward slashes, so a path like /\evil.com sails through the prefix check and resolves off-origin. Compare resolved origins, not string prefixes.
The takeaway
A config flag is an interface change. If turning it on adds a requirement, every client that talks to that system inherited the requirement the moment you flipped it — so enumerate the callers before, and enforce after everyone can comply.
Capabilities this touches