Summary
Two concurrent POST /factors/{id}/verify requests for the same user make one of them fail with
a 500 unexpected_failure, where the honest answer is a 4xx conflict. The cause is that a
successful TOTP verification unconditionally deletes every aal1 session of that user, in the
same transaction that another in-flight verification is still building its session in.
This is not a "don't do that" case: signing the same account in from two places at once is ordinary
(a browser tab and a CI job, two devices, a test harness and a human). The security behaviour is
correct and I am not asking for it to change — only for the failure to be reported as a conflict
rather than as an internal error, and ideally to be avoided where it is avoidable.
Mechanism
internal/api/mfa.go, TOTP verify transaction (current master):
token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, ...)
if terr != nil {
return terr
}
if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil {
return apierrors.NewInternalServerError("Failed to update sessions. %s", terr)
}
and internal/models/sessions.go:
func InvalidateSessionsWithAALLessThan(tx *storage.Connection, userID uuid.UUID, level string) error {
return tx.RawQuery("DELETE FROM "+... +" WHERE user_id = ? AND aal < ?", userID, level).Exec()
}
Two details make this observable:
-
The DELETE is user-scoped, not session-scoped. Request A's successful verify deletes request
B's aal1 session, which B is at that moment mid-way through upgrading.
-
updateMFASessionAndClaims writes before it checks. In internal/api/token.go:
if terr := models.AddClaimToSession(tx, sessionId, authenticationMethod); terr != nil {
return terr
}
session, terr := models.FindSessionByID(tx, sessionId, true) // existence check + FOR UPDATE
The mfa_amr_claims INSERT — which carries an FK to sessions.id — happens before the
existence check and before the row lock. If A's DELETE commits in that window, B's INSERT hits
the foreign key.
Observed
GoTrue v2.189.0, Postgres 15. Three distinct outcomes, all on POST /factors/{id}/verify, all from
the same underlying race:
403 session_not_found "Session from session_id claim in JWT does not exist"
500 unexpected_failure "Failed to update sessions. ERROR: deadlock detected (SQLSTATE 40P01)"
500 unexpected_failure insert or update on table "mfa_amr_claims" violates foreign key
constraint "mfa_amr_claims_session_id_fkey" (SQLSTATE 23503)
The 403 is arguably fine. The two 500s are the report: a client that lost a race against another
client of the same account gets an internal-error shape it cannot distinguish from a server fault,
and every retry policy treats it accordingly.
Deadlock detail: the DELETE takes row locks in arbitrary order (no ORDER BY, no SKIP LOCKED), so
two concurrent invalidations for the same user can deadlock against each other rather than serialise.
Reproduction
Reproduced deliberately, twice, ~2 minutes apart:
- one client repeatedly completes a full password-grant → challenge → verify cycle for account X;
- a second client does the same for account X, concurrently;
- within ~2 minutes at a few requests per second, one side produces
40P01 and/or
session_not_found.
Control: pointing the second client at a different account, with everything else identical and
in the same minute, produced 5/5 clean runs against 29 successful step-ups of the first account —
so the trigger is specifically same-user concurrency, not load.
Two request IDs from an earlier, unprompted occurrence in CI, if they are useful:
436d595c-f316-4e7a-8c4f-88f128164190 (won the race) and
3f39394b-af5d-43ea-b858-632631cca7cd (the 500). Error ID from the reproduction:
296da493-678f-4f6e-8d1d-e0a66075a8f1.
Suggested directions
Roughly in increasing order of ambition — any one of them would resolve the reported problem:
- Map the conflict to a 4xx. Translate
23503 on mfa_amr_claims_session_id_fkey and 40P01
from this transaction into a conflict-shaped error (e.g. 409 with a dedicated error code)
rather than NewInternalServerError. This is the minimal fix and the one I would want.
- Check before writing. Move
FindSessionByID(tx, sessionId, true) ahead of
AddClaimToSession in updateMFASessionAndClaims, so the session is locked and proven to exist
before a row referencing it is inserted. That converts the FK violation into the existing, clean
session_not_found path.
- Make the invalidation deadlock-resistant — a deterministic lock order (
... WHERE user_id = ? AND aal < ? ORDER BY id FOR UPDATE before the delete) or an explicit advisory lock on the user
for the duration of the verify.
- Exclude the caller's own session from the invalidation where the flow already knows it, so
the common "same user, two verifies" case stops being destructive at all. (This one changes
behaviour, so it is the least safe suggestion of the four.)
I am happy to open a PR for (1) or (2) if that would be welcome — say which shape you would accept.
Not the issue
- Not a version regression:
sessions.go, amr.go and the relevant part of mfa.go are unchanged
from v2.189.0 through current master, so upgrading does not affect it. I re-read master before
filing.
- Not a bad TOTP secret, not clock skew, and not a replayed code — the losing request's own code was
accepted in the same window on retry.
Summary
Two concurrent
POST /factors/{id}/verifyrequests for the same user make one of them fail witha
500 unexpected_failure, where the honest answer is a 4xx conflict. The cause is that asuccessful TOTP verification unconditionally deletes every
aal1session of that user, in thesame transaction that another in-flight verification is still building its session in.
This is not a "don't do that" case: signing the same account in from two places at once is ordinary
(a browser tab and a CI job, two devices, a test harness and a human). The security behaviour is
correct and I am not asking for it to change — only for the failure to be reported as a conflict
rather than as an internal error, and ideally to be avoided where it is avoidable.
Mechanism
internal/api/mfa.go, TOTP verify transaction (currentmaster):and
internal/models/sessions.go:Two details make this observable:
The DELETE is user-scoped, not session-scoped. Request A's successful verify deletes request
B's
aal1session, which B is at that moment mid-way through upgrading.updateMFASessionAndClaimswrites before it checks. Ininternal/api/token.go:The
mfa_amr_claimsINSERT — which carries an FK tosessions.id— happens before theexistence check and before the row lock. If A's DELETE commits in that window, B's INSERT hits
the foreign key.
Observed
GoTrue v2.189.0, Postgres 15. Three distinct outcomes, all on
POST /factors/{id}/verify, all fromthe same underlying race:
The
403is arguably fine. The two500s are the report: a client that lost a race against anotherclient of the same account gets an internal-error shape it cannot distinguish from a server fault,
and every retry policy treats it accordingly.
Deadlock detail: the DELETE takes row locks in arbitrary order (no
ORDER BY, noSKIP LOCKED), sotwo concurrent invalidations for the same user can deadlock against each other rather than serialise.
Reproduction
Reproduced deliberately, twice, ~2 minutes apart:
40P01and/orsession_not_found.Control: pointing the second client at a different account, with everything else identical and
in the same minute, produced 5/5 clean runs against 29 successful step-ups of the first account —
so the trigger is specifically same-user concurrency, not load.
Two request IDs from an earlier, unprompted occurrence in CI, if they are useful:
436d595c-f316-4e7a-8c4f-88f128164190(won the race) and3f39394b-af5d-43ea-b858-632631cca7cd(the 500). Error ID from the reproduction:296da493-678f-4f6e-8d1d-e0a66075a8f1.Suggested directions
Roughly in increasing order of ambition — any one of them would resolve the reported problem:
23503onmfa_amr_claims_session_id_fkeyand40P01from this transaction into a conflict-shaped error (e.g.
409with a dedicated error code)rather than
NewInternalServerError. This is the minimal fix and the one I would want.FindSessionByID(tx, sessionId, true)ahead ofAddClaimToSessioninupdateMFASessionAndClaims, so the session is locked and proven to existbefore a row referencing it is inserted. That converts the FK violation into the existing, clean
session_not_foundpath.... WHERE user_id = ? AND aal < ? ORDER BY id FOR UPDATEbefore the delete) or an explicit advisory lock on the userfor the duration of the verify.
the common "same user, two verifies" case stops being destructive at all. (This one changes
behaviour, so it is the least safe suggestion of the four.)
I am happy to open a PR for (1) or (2) if that would be welcome — say which shape you would accept.
Not the issue
sessions.go,amr.goand the relevant part ofmfa.goare unchangedfrom v2.189.0 through current
master, so upgrading does not affect it. I re-readmasterbeforefiling.
accepted in the same window on retry.