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"}
Enter fullscreen mode Exit fullscreen mode
and on production:
HTTP 500
{"detail":"Authentication failed: User does not exist."}
Enter fullscreen mode Exit fullscreen mode
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.