The Bug That Turned Every Bad Password Into a Server Outage
Type your password wrong on CloudWise's login page, and for a while, the server told you it had a nervous breakdown.
Not "invalid credentials." Not even a plain 401. An HTTP 500 — the code reserved for "something on our end is broken" — for the most routine failure mode there is: a human mistyping a password.
This happened on staging. It happened on production. It happened to POST /api/v1/auth/login with wrong creds, an unknown email, an unconfirmed account, or an account mid-password-reset. Four different "you did something ordinary" situations, all reported back as "we did something wrong." It went undetected until an automated gate caught it and refused to let a release ship. This is that bug, the fix, and why the distinction matters more than it sounds like it should.
The bug
_handle_cognito_auth_error, the function that translates AWS Cognito's auth exceptions into an HTTP response, raised a CloudWiseException without ever setting a status_code. No status code means the default. The default is 500.
So every one of these:
- Wrong password
- Email that isn't registered
- Account that hasn't confirmed its email yet
- Account stuck in a forced password reset
came back as a server error. Reproduced live on both environments — for example, POST /api/v1/auth/login with bad credentials on staging returned:
HTTP 500
{"detail":"Invalid email or password"}
and on production:
HTTP 500
{"detail":"Authentication failed: User does not exist."}
Read that second one again. Production wasn't just returning the wrong status code — it was telling an anonymous caller whether a given email address had an account. That's a second, smaller bug riding along inside the first one.
Why the status code is the actual bug
It's tempting to shrug this off — the message was right there in the body, "Invalid email or password", so what's the harm in the wrong three-digit prefix?
The harm is that the status code isn't decoration. It's the part of the response that infrastructure reads without understanding a word of the payload. A monitoring dashboard doesn't parse detail. It counts 5xx rates. To CloudWise's own alerting, every single mistyped password looked identical to an actual outage — same bucket, same page-worthy signal, same "something is on fire" pattern, forever, at whatever rate normal users normally fat-finger their passwords. That's not a rare event. It's baseline noise, and the bug was quietly dressing it up as baseline crisis.
It also meant every one of those routine rejections logged at error level — the log level meant for things that need a human to look at them, now firing constantly for people who just needed to try again.
And then it did something worse than annoy a dashboard: it blocked a production release.
How it actually got caught
CloudWise's release pipeline runs an E2E gate against staging before anything is allowed to promote to production. One of the negative-path assertions in that gate — ci-gate-read.spec.ts:93 — logs in with a wrong password and checks that the app shows an error and stays on /auth/login, the way a real login form should behave.
That test didn't expect a 500. Nothing in a normal auth flow should return one for a wrong password. The gate went red, and release 1.105.0 sat there, unable to auto-promote to production, because the pipeline correctly refused to trust a build where the login form's error handling was behaving like a crash.
That's the part worth sitting with: this bug was already live in production, unnoticed, for who knows how long. It took a different release's gate run to surface it — not because the gate was looking for this specific bug, but because it was asserting the right general behavior (bad password → clean error, stay put) and the actual behavior didn't match. A negative-path test doesn't need to know about your bug in advance. It just needs to check that the ordinary failure case looks ordinary.
The fix
The fix, in backend/app/services/cognito_auth_service.py, maps Cognito's exceptions to the status codes they should have had all along:
NotAuthorizedException(wrong password) andUserNotFoundException(unknown email) → 401, both returning the exact same message:"Invalid email or password". Same message for both cases on purpose — the response can no longer be used to tell whether an email is registered, closing that account-enumeration leak.UserNotConfirmedExceptionandPasswordResetRequiredException→ 403 — the account exists, but the request is correctly rejected for a reason that isn't "try a different password."- Anything else — a genuinely unexpected Cognito error — still defaults to 500. That default is correct in that case. An unrecognized failure mode is a server-side concern worth alerting on. The bug was never that 500 existed; it was that the four most common, most expected auth rejections were routed into it by omission.
Expected-rejection logging dropped from error to warning, so the logs now reflect what actually happened: a routine, anticipated rejection, not an incident.
The test suite got the fix that should have caught this the first time. test_handle_cognito_auth_error_* previously asserted on the error message only — never the status code. That's exactly how a 500-instead-of-401 slips through code review and CI both: the message text looked fine, so nobody noticed the number in front of it was wrong. The tests now assert status codes explicitly, plus two cases that weren't covered before: the no-leak UserNotFoundException path, and PasswordResetRequiredException.
This is the sibling of an earlier fix, CLO-48, which cleaned up the same bug class on the refresh-token path — an expired refresh token was logging as ERROR twice when a clean 401 was all that was warranted. That one didn't touch login. This one closes the login path CLO-48 didn't cover.
The takeaway
Status codes are a contract between your API and everything that reads it without understanding it — monitors, alerting rules, retry logic, browsers, other services. A wrong password is a client error. Treating it as a server error doesn't just mislabel one response; it teaches every downstream system that watches your 5xx rate to distrust the signal, right when you need that signal to mean something.
The thing that actually caught this wasn't a code review, a manual QA pass, or a customer complaint. It was an automated negative-path assertion doing exactly what negative-path assertions are for: checking that the boring, expected failure looks boring and expected. It's staying in the gate. Nobody has to remember to test this again — the pipeline already refuses to ship a build that gets it wrong.
If you want to see what else that kind of scrutiny turns up, a free, read-only scan of your AWS account takes about five minutes and changes nothing without you approving it first.
Stop wasting money on AWS
CloudWise monitors 45 AWS services and finds waste automatically. Free forever.
Start Free Scan →