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.
What
ResourceOwnerPasswordGrantperforms 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:Authenticateis a method on*Userand is the only caller ofcrypto.CompareHashAndPasswordon 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: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_credentialserror, so timing no longer depends on existence.dummyHashshould be generated at startup at the configured cost, so it tracksbcrypt.DefaultCostinstead of drifting from it.Prior art: Django's
set_passworddummy-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,resetPasswordForEmailreturning 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.