Skip to content

Password grant leaks account existence via timing: no hash verify on the unknown-user path #2674

Description

@fireandsafetyca-lang

What

ResourceOwnerPasswordGrant performs the bcrypt/argon2 comparison only when the address resolves to a user. On an unknown address it returns before any comparable work, so response time discloses whether an account exists.

I know user enumeration is out of scope for the VDP, so I'm raising this as a hardening request on the open-source auth code, not as a vulnerability report. The fix is a well-established few-line pattern and is contained to one function.

Where

internal/api/token.go, ResourceOwnerPasswordGrant:

user, err = models.FindUserByEmailAndAudience(db, params.Email, aud)
...
if models.IsNotFoundError(err) {
    return apierrors.NewBadRequestError(apierrors.ErrorCodeInvalidCredentials, InvalidLoginMessage)  // returns here
}
...
isValidPassword, shouldReEncrypt, err := user.Authenticate(ctx, db, params.Password, ...)  // only when the user exists

Authenticate is a method on *User and is the only caller of crypto.CompareHashAndPassword on this path, so it is structurally unreachable without a found user. There is no decoy hash or compensating work on the not-found branch, and I found no configuration option to enable one.

Measurement

Against a hosted Supabase project, POST /token?grant_type=password, existing address + wrong password vs. an unknown address:

run 1  +74.4 ms
run 2  +79.7 ms
run 3  +78.9 ms
run 4  +77.0 ms

Four independent runs. Minimum (floor), not median — scheduling jitter only ever adds time, so the median understates the gap; the floor is the true signal. A control comparing two different nonexistent addresses measured +1.9 ms, confirming the harness reports "no gap" when there is none rather than always finding one.

The magnitude matches a bcrypt verify at default cost, which is what the code says should be happening on exactly one of the two branches.

Suggested fix

The standard mitigation: on the unknown-user path, run a comparison against a fixed decoy hash before returning the same invalid_credentials error, so timing no longer depends on existence.

if models.IsNotFoundError(err) {
    // Consume comparable work so response time doesn't disclose existence.
    _ = crypto.CompareHashAndPassword(ctx, dummyHash, params.Password)
    return apierrors.NewBadRequestError(apierrors.ErrorCodeInvalidCredentials, InvalidLoginMessage)
}

dummyHash should be generated at startup at the configured cost, so it tracks bcrypt.DefaultCost instead of drifting from it.

Prior art: Django's set_password dummy-runner, Devise's paranoid mode, and the OWASP Authentication Cheat Sheet ("uniform timing" / protect against automated attacks).

Not a duplicate

I searched before filing. #1955, #1547, #1517, #569 and #338 are all response-content differences (signInWithOtp, signUp, resetPasswordForEmail returning distinguishable errors). This is a timing difference on the password grant — different endpoint, different mechanism, different fix. Nothing in the current tree addresses it.

Happy to open a PR if the approach looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions