Login error responses enable account enumeration via HTTP status codes #8

Open
opened 2026-04-02 11:20:46 +02:00 by marcel · 5 comments
Owner

Problem

The login flow in AuthService.login() throws different exception types for "user not found" vs "wrong password":

  • User not found → ResourceNotFoundException → HTTP 404
  • Wrong password → ValidationException → HTTP 422

Even though both messages say "Invalid email or password", the HTTP status code reveals whether the account exists.

Affected files

  • AuthService.java:45-52

Attack scenario

An attacker sends login requests and observes the status code:

  • 404 → email does not exist
  • 422 → email exists, password was wrong

This enables building a list of valid email addresses before attempting credential stuffing.

Both cases should throw the same exception type returning the same HTTP status code (e.g., 401 Unauthorized).

Severity

High — account enumeration enables targeted attacks.

## Problem The login flow in `AuthService.login()` throws different exception types for "user not found" vs "wrong password": - User not found → `ResourceNotFoundException` → HTTP 404 - Wrong password → `ValidationException` → HTTP 422 Even though both messages say "Invalid email or password", the HTTP status code reveals whether the account exists. ## Affected files - `AuthService.java:45-52` ## Attack scenario An attacker sends login requests and observes the status code: - 404 → email does not exist - 422 → email exists, password was wrong This enables building a list of valid email addresses before attempting credential stuffing. ## Recommended fix Both cases should throw the same exception type returning the same HTTP status code (e.g., 401 Unauthorized). ## Severity High — account enumeration enables targeted attacks.
marcel added the kind/securitypriority/high labels 2026-04-02 11:21:05 +02:00
Author
Owner

👨‍💻 Kai — Frontend Engineer

The fix happens entirely in AuthService.java, but the status code change to 401 will touch our SvelteKit error handling — here's what I want to make sure we get right:

Frontend error handling in the login flow:

  • Currently, our +page.server.ts login action probably distinguishes responses by status code to decide what message to show. If we're unifying to 401, we need to make sure the action returns the same generic message regardless — no accidentally branching on 404 vs. 422 in the SvelteKit handler.
  • The user-facing message should be one string: something like "E-Mail oder Passwort ungültig" — no variation between "account not found" and "wrong password." This must be enforced both in the backend response body and in whatever the frontend displays from the fail() return.

No new UI components needed, but I'd like to audit the login +page.server.ts action to confirm it's not currently showing different messages for different status codes. If it is, that's a bug on our side too.

Questions:

  • Does the current login action in +page.server.ts branch on the HTTP status from the backend API, or does it always show a fixed message? If it branches, we need to fix that in this same issue.
  • After the fix, will the 401 response body still contain the generic "Invalid email or password" message, or will it be empty? I need to know what the frontend can safely render.
## 👨‍💻 Kai — Frontend Engineer The fix happens entirely in `AuthService.java`, but the status code change to 401 will touch our SvelteKit error handling — here's what I want to make sure we get right: **Frontend error handling in the login flow:** - Currently, our `+page.server.ts` login action probably distinguishes responses by status code to decide what message to show. If we're unifying to 401, we need to make sure the action returns the same generic message regardless — no accidentally branching on 404 vs. 422 in the SvelteKit handler. - The user-facing message should be one string: something like "E-Mail oder Passwort ungültig" — no variation between "account not found" and "wrong password." This must be enforced both in the backend response body and in whatever the frontend displays from the `fail()` return. **No new UI components needed**, but I'd like to audit the login `+page.server.ts` action to confirm it's not currently showing different messages for different status codes. If it is, that's a bug on our side too. Questions: - Does the current login action in `+page.server.ts` branch on the HTTP status from the backend API, or does it always show a fixed message? If it branches, we need to fix that in this same issue. - After the fix, will the 401 response body still contain the generic `"Invalid email or password"` message, or will it be empty? I need to know what the frontend can safely render.
Author
Owner

🔧 Backend Engineer — Spring Boot / PostgreSQL Specialist

High priority and a clean fix — but a few implementation details worth getting right:

The right exception to throw:

  • Both "user not found" and "wrong password" should throw the same exception type, mapping to HTTP 401. I'd introduce a dedicated AuthenticationFailedException (or reuse Spring Security's BadCredentialsException) and add a handler in GlobalExceptionHandler that maps it to 401 with a fixed body.
  • Avoid reusing ResourceNotFoundException (which semantically means 404) for auth failures — the naming leaks the intent even if we remap the status.

Timing attack consideration:

  • Right now, "user not found" likely returns faster than "wrong password" (no bcrypt comparison needed). Even with the same status code, timing differences can still reveal whether the account exists.
  • The fix should ensure the bcrypt comparison always runs, even when the user isn't found. A common pattern: load a dummy UserDetails and run passwordEncoder.matches(rawPassword, dummyHash) to normalize timing.

Layer placement:

  • The fix belongs in AuthService, not the controller — the controller should only map exceptions to responses via GlobalExceptionHandler. Confirm the business rule (unified failure) lives in the service, not in a try/catch in the controller.

Signup flow:

  • Should the signup endpoint also return 409 (conflict) rather than a message that reveals whether an email is registered? Worth checking AuthService for the signup path too.

Questions:

  • Is AuthService.login() currently throwing ResourceNotFoundException directly, or is it using a UserDetailsService that throws Spring Security exceptions? The fix path differs.
  • Do we have any rate limiting or lockout mechanism on the login endpoint? Even with unified error codes, credential stuffing is still possible without rate limiting.
## 🔧 Backend Engineer — Spring Boot / PostgreSQL Specialist High priority and a clean fix — but a few implementation details worth getting right: **The right exception to throw:** - Both "user not found" and "wrong password" should throw the same exception type, mapping to HTTP 401. I'd introduce a dedicated `AuthenticationFailedException` (or reuse Spring Security's `BadCredentialsException`) and add a handler in `GlobalExceptionHandler` that maps it to 401 with a fixed body. - Avoid reusing `ResourceNotFoundException` (which semantically means 404) for auth failures — the naming leaks the intent even if we remap the status. **Timing attack consideration:** - Right now, "user not found" likely returns faster than "wrong password" (no bcrypt comparison needed). Even with the same status code, timing differences can still reveal whether the account exists. - The fix should ensure the bcrypt comparison always runs, even when the user isn't found. A common pattern: load a dummy `UserDetails` and run `passwordEncoder.matches(rawPassword, dummyHash)` to normalize timing. **Layer placement:** - The fix belongs in `AuthService`, not the controller — the controller should only map exceptions to responses via `GlobalExceptionHandler`. Confirm the business rule (unified failure) lives in the service, not in a `try/catch` in the controller. **Signup flow:** - Should the signup endpoint also return 409 (conflict) rather than a message that reveals whether an email is registered? Worth checking `AuthService` for the signup path too. Questions: - Is `AuthService.login()` currently throwing `ResourceNotFoundException` directly, or is it using a `UserDetailsService` that throws Spring Security exceptions? The fix path differs. - Do we have any rate limiting or lockout mechanism on the login endpoint? Even with unified error codes, credential stuffing is still possible without rate limiting.
Author
Owner

🧪 QA Engineer

This is a high-value security fix that also needs careful regression testing — changing the login error behavior touches a critical user-facing path. Here's the test matrix I'd want covered:

Unit tests (AuthService):

  • shouldReturn401WhenEmailDoesNotExist() — previously returned 404, must now return 401
  • shouldReturn401WhenPasswordIsWrong() — previously returned 422, must now return 401
  • shouldReturnSameBodyForBothFailureCases() — message text is identical in both scenarios
  • shouldNotLeakWhetherEmailExistsInErrorBody() — body never says "user not found" vs "password incorrect"

Integration tests (full request cycle):

  • POST /api/auth/login with non-existent email → 401, generic message
  • POST /api/auth/login with valid email + wrong password → 401, same generic message
  • POST /api/auth/login with valid credentials → 200, session established
  • Verify response body structure is identical for both failure cases (not just status code)

Timing test (exploratory / manual):

  • Measure response time for "no such user" vs "wrong password" — if there's a significant timing delta, the timing attack vector is still exploitable even after the status code fix

Regression — existing login tests:

  • Any existing test that asserts a 404 or 422 for login failure will need to be updated to expect 401 — this is a deliberate behavior change, not a regression

Edge cases:

  • POST /api/auth/login with empty email field → should this also be 401 or 400/422? Need to decide and document
  • POST /api/auth/login with SQL-like injection in email field → 401 (not a 500, which would indicate the query is executing)
  • Very long email strings — consistent 401, not a timeout or 500

Questions:

  • After this fix, is there a test that explicitly verifies both failure paths return identical response bodies, not just the same status? That's the critical property to lock in.
  • Has the signup flow been audited for similar information leakage (e.g., does "email already registered" expose account existence)? Should that be a linked issue?
## 🧪 QA Engineer This is a high-value security fix that also needs careful regression testing — changing the login error behavior touches a critical user-facing path. Here's the test matrix I'd want covered: **Unit tests (AuthService):** - `shouldReturn401WhenEmailDoesNotExist()` — previously returned 404, must now return 401 - `shouldReturn401WhenPasswordIsWrong()` — previously returned 422, must now return 401 - `shouldReturnSameBodyForBothFailureCases()` — message text is identical in both scenarios - `shouldNotLeakWhetherEmailExistsInErrorBody()` — body never says "user not found" vs "password incorrect" **Integration tests (full request cycle):** - POST `/api/auth/login` with non-existent email → 401, generic message - POST `/api/auth/login` with valid email + wrong password → 401, same generic message - POST `/api/auth/login` with valid credentials → 200, session established - Verify response body structure is identical for both failure cases (not just status code) **Timing test (exploratory / manual):** - Measure response time for "no such user" vs "wrong password" — if there's a significant timing delta, the timing attack vector is still exploitable even after the status code fix **Regression — existing login tests:** - Any existing test that asserts a 404 or 422 for login failure will need to be updated to expect 401 — this is a deliberate behavior change, not a regression **Edge cases:** - POST `/api/auth/login` with empty email field → should this also be 401 or 400/422? Need to decide and document - POST `/api/auth/login` with SQL-like injection in email field → 401 (not a 500, which would indicate the query is executing) - Very long email strings — consistent 401, not a timeout or 500 Questions: - After this fix, is there a test that explicitly verifies both failure paths return *identical* response bodies, not just the same status? That's the critical property to lock in. - Has the signup flow been audited for similar information leakage (e.g., does "email already registered" expose account existence)? Should that be a linked issue?
Author
Owner

🔐 Sable — Security Engineer

This is a classic OWASP A07 (Authentication Failures) vulnerability and it's correctly labeled high. The attack scenario in the issue is accurate. A few things to make sure the fix is complete and not just surface-deep:

The status code fix is necessary but not sufficient:

  • Same status code (401) is the right call. But attackers can also distinguish paths via response time. If the "user not found" branch skips bcrypt comparison, it will return in ~1ms while the "wrong password" branch takes ~100ms (bcrypt cost). This timing oracle still enables enumeration.
  • Fix: always run passwordEncoder.matches() even when the user isn't found. Use a pre-computed dummy hash for this purpose — a static constant in AuthService, not a fresh hash each call.

Response body uniformity:

  • Confirm the response body, Content-Type, and Content-Length are identical for both failure cases. Any difference — including whitespace — can be fingerprinted.

Signup enumeration:

  • The signup endpoint POST /api/auth/register (or equivalent) likely returns a different error when the email already exists (e.g., 409 Conflict). This is an explicit account enumeration vector. Should be tracked as a companion issue or included in this one's scope.

Related attack: credential stuffing

  • Unifying the error code removes one enumeration vector, but doesn't stop credential stuffing. Is there any rate limiting (Spring's RateLimiter, an API gateway rule, or nginx limit_req) on the login endpoint? If not, that's the next thing to address after this fix.

Audit log for failed logins:

  • Failed login attempts should be logged to admin_audit_log (or a separate security event log) with the attempted email and source IP — this feeds into incident detection. Is that currently happening?

Questions:

  • Has this been verified in a running environment, or only via code review? I'd want to confirm the status code behavior with an actual HTTP client before closing.
  • Is the GlobalExceptionHandler mapping ResourceNotFoundException → 404 globally? If so, the fix must ensure the new auth exception type is not a subclass of ResourceNotFoundException.
## 🔐 Sable — Security Engineer This is a classic OWASP A07 (Authentication Failures) vulnerability and it's correctly labeled high. The attack scenario in the issue is accurate. A few things to make sure the fix is complete and not just surface-deep: **The status code fix is necessary but not sufficient:** - Same status code (401) is the right call. But attackers can also distinguish paths via **response time**. If the "user not found" branch skips bcrypt comparison, it will return in ~1ms while the "wrong password" branch takes ~100ms (bcrypt cost). This timing oracle still enables enumeration. - Fix: always run `passwordEncoder.matches()` even when the user isn't found. Use a pre-computed dummy hash for this purpose — a static constant in `AuthService`, not a fresh hash each call. **Response body uniformity:** - Confirm the response body, `Content-Type`, and `Content-Length` are identical for both failure cases. Any difference — including whitespace — can be fingerprinted. **Signup enumeration:** - The signup endpoint `POST /api/auth/register` (or equivalent) likely returns a different error when the email already exists (e.g., 409 Conflict). This is an explicit account enumeration vector. Should be tracked as a companion issue or included in this one's scope. **Related attack: credential stuffing** - Unifying the error code removes one enumeration vector, but doesn't stop credential stuffing. Is there any rate limiting (Spring's `RateLimiter`, an API gateway rule, or nginx `limit_req`) on the login endpoint? If not, that's the next thing to address after this fix. **Audit log for failed logins:** - Failed login attempts should be logged to `admin_audit_log` (or a separate security event log) with the attempted email and source IP — this feeds into incident detection. Is that currently happening? Questions: - Has this been verified in a running environment, or only via code review? I'd want to confirm the status code behavior with an actual HTTP client before closing. - Is the `GlobalExceptionHandler` mapping `ResourceNotFoundException` → 404 globally? If so, the fix must ensure the new auth exception type is *not* a subclass of `ResourceNotFoundException`.
Author
Owner

🎨 Atlas — UI/UX Designer

The fix is purely backend, but it directly affects what the login screen communicates to the user. Let me make sure the UX intent is consistent:

Error message copy (German UI):

  • The current implementation already uses a generic message — good. After the fix, the message should remain a single, friendly phrase like: "E-Mail-Adresse oder Passwort ungültig." — no variation based on which branch failed.
  • We should never show technical language like "authentication failed" or "401 unauthorized" in the UI. The SvelteKit action layer must translate the backend error into user-friendly German copy before rendering.

Error state design on the login form:

  • The error message should appear inline below the form (not as a toast/banner), since the user needs to correct their input immediately. It should use --color-error for the text and ideally a short descriptive icon.
  • The email and password fields should not individually highlight in red for a failed login — we don't want to visually suggest which field was wrong (that would be a UX-level enumeration hint).

No new components needed for this fix — but if the login form currently highlights individual fields differently for auth failures vs. validation failures, that needs to be unified.

Questions:

  • Is the login error currently displayed as a field-level error (next to the email or password input) or as a form-level error (below the submit button)? If it's field-level, we should reconsider the placement to avoid implying which field is wrong.
  • What's the current German copy for the login error message? I'd like to verify it's neutral and not accidentally revealing.
## 🎨 Atlas — UI/UX Designer The fix is purely backend, but it directly affects what the login screen communicates to the user. Let me make sure the UX intent is consistent: **Error message copy (German UI):** - The current implementation already uses a generic message — good. After the fix, the message should remain a single, friendly phrase like: `"E-Mail-Adresse oder Passwort ungültig."` — no variation based on which branch failed. - We should never show technical language like "authentication failed" or "401 unauthorized" in the UI. The SvelteKit action layer must translate the backend error into user-friendly German copy before rendering. **Error state design on the login form:** - The error message should appear inline below the form (not as a toast/banner), since the user needs to correct their input immediately. It should use `--color-error` for the text and ideally a short descriptive icon. - The email and password fields should not individually highlight in red for a failed login — we don't want to visually suggest which field was wrong (that would be a UX-level enumeration hint). **No new components needed** for this fix — but if the login form currently highlights individual fields differently for auth failures vs. validation failures, that needs to be unified. Questions: - Is the login error currently displayed as a field-level error (next to the email or password input) or as a form-level error (below the submit button)? If it's field-level, we should reconsider the placement to avoid implying which field is wrong. - What's the current German copy for the login error message? I'd like to verify it's neutral and not accidentally revealing.
Sign in to join this conversation.