Bug report
Describe the bug
Every successful MFA factor verification deletes all of the user's aal1 sessions — including
sessions the OAuth 2.1 server issued to third-party clients. An OAuth client session is created at
aal1 (/oauth/token → IssueRefreshToken → models.NewSession, which hard-codes AAL1;
ApplyGrantParams never touches aal) and in practice never performs MFA — the authorization-code
flow gives the client no step at which to do so. So its refresh token is invalidated the next time
the user does an MFA step-up in a first-party app. From the client's side this looks like a random
refresh_token_not_found and a forced re-authorization.
Every MFA verify path calls the same function — the three handlers in internal/api/mfa.go
(verifyTOTPFactor L701, verifyPhoneFactor L842, verifyWebAuthnFactor L962) and
RecoveryCodesVerify in internal/api/recovery_codes.go L430 (added in #2764, merged 2026-09-02):
models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String())
which is (internal/models/sessions.go L353):
func InvalidateSessionsWithAALLessThan(tx *storage.Connection, userID uuid.UUID, level string) error {
return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Session{}}).TableName()+" WHERE user_id = ? AND aal < ?", userID, level).Exec()
}
There is no AND oauth_client_id IS NULL. The model already distinguishes these sessions
(Session.OAuthClientID, L96) and already has a dedicated lifecycle function for them
(RevokeOAuthSessions, L373, keyed on user_id AND oauth_client_id) — the MFA path just does not
use that distinction.
Line numbers are against master as of 2026-09-12; the behaviour is unchanged through v2.197.0.
To Reproduce
- User has a verified TOTP factor.
- User authorizes an OAuth 2.1 client via
/oauth/authorize → auth.sessions gets a row with
oauth_client_id set and aal = 'aal1'.
- Same user signs in to a first-party app (session
aal1) and completes POST /factors/{id}/verify.
SELECT id, aal, oauth_client_id FROM auth.sessions WHERE user_id = … — the OAuth client's row is
gone; only the freshly verified aal2 browser session remains.
- The client's next
grant_type=refresh_token to /oauth/token returns
400 refresh_token_not_found.
Observed on hosted Supabase (GoTrue v2.196.0, OAuth 2.1 server enabled, public client,
token_endpoint_auth_method: none) on 2026-09-12: an existing OAuth client session (aal1),
POST /factors/{id}/verify → 200 at 14:28:48 UTC → the OAuth session row is gone; the freshly
verified aal2 session is the only one left. The client's next refresh (15:06:21 UTC)
returned 400 refresh_token_not_found and it re-ran the authorization flow. No /logout or admin
mutation for this user occurred in between. The OAuth client in our case is a Model Context Protocol
connector in Claude.ai, which then prompts the user to reconnect.
Expected behavior
MFA step-up should invalidate only the user's own lower-AAL sessions — the browser/device sessions
that could have been upgraded but were not. A session issued to an OAuth client represents a
consented grant, not an authentication level the user can raise; nothing in the OAuth flow ever
upgrades it, so it is aal1 for its whole life. Its lifecycle should be governed by consent
revocation (RevokeOAuthSessions), not by the user's MFA state in an unrelated app.
Suggested fix, smallest change:
"DELETE FROM … WHERE user_id = ? AND aal < ? AND oauth_client_id IS NULL"
Alternative: stamp OAuth client sessions with the AAL of the authorizing session at issuance (the
auth middleware already puts the approving session in context for the consent handler), so a grant
made from an aal2 browser session survives later step-ups.
Screenshots
Not applicable — the evidence is the auth.sessions row set before/after the verify and the
/oauth/token response, both quoted above.
System information
- OS: n/a (server-side; hosted Supabase Auth)
- Browser (if applies): n/a
- Version of supabase-js: 2.99.3 (first-party app performing the MFA step-up; not involved in the
OAuth client's refresh path)
- Version of Node.js: n/a
- Auth: GoTrue v2.196.0 (hosted); behaviour unchanged on
master as of v2.197.0
- OAuth 2.1 server enabled; public client (
token_endpoint_auth_method: none)
- MFA: TOTP
Additional context
Bug report
Describe the bug
Every successful MFA factor verification deletes all of the user's
aal1sessions — includingsessions the OAuth 2.1 server issued to third-party clients. An OAuth client session is created at
aal1(/oauth/token→IssueRefreshToken→models.NewSession, which hard-codesAAL1;ApplyGrantParamsnever touchesaal) and in practice never performs MFA — the authorization-codeflow gives the client no step at which to do so. So its refresh token is invalidated the next time
the user does an MFA step-up in a first-party app. From the client's side this looks like a random
refresh_token_not_foundand a forced re-authorization.Every MFA verify path calls the same function — the three handlers in
internal/api/mfa.go(
verifyTOTPFactorL701,verifyPhoneFactorL842,verifyWebAuthnFactorL962) andRecoveryCodesVerifyininternal/api/recovery_codes.goL430 (added in #2764, merged 2026-09-02):which is (
internal/models/sessions.goL353):There is no
AND oauth_client_id IS NULL. The model already distinguishes these sessions(
Session.OAuthClientID, L96) and already has a dedicated lifecycle function for them(
RevokeOAuthSessions, L373, keyed onuser_id AND oauth_client_id) — the MFA path just does notuse that distinction.
Line numbers are against
masteras of 2026-09-12; the behaviour is unchanged through v2.197.0.To Reproduce
/oauth/authorize→auth.sessionsgets a row withoauth_client_idset andaal = 'aal1'.aal1) and completesPOST /factors/{id}/verify.SELECT id, aal, oauth_client_id FROM auth.sessions WHERE user_id = …— the OAuth client's row isgone; only the freshly verified
aal2browser session remains.grant_type=refresh_tokento/oauth/tokenreturns400 refresh_token_not_found.Observed on hosted Supabase (GoTrue v2.196.0, OAuth 2.1 server enabled, public client,
token_endpoint_auth_method: none) on 2026-09-12: an existing OAuth client session (aal1),POST /factors/{id}/verify→ 200 at 14:28:48 UTC → the OAuth session row is gone; the freshlyverified
aal2session is the only one left. The client's next refresh (15:06:21 UTC)returned
400 refresh_token_not_foundand it re-ran the authorization flow. No/logoutor adminmutation for this user occurred in between. The OAuth client in our case is a Model Context Protocol
connector in Claude.ai, which then prompts the user to reconnect.
Expected behavior
MFA step-up should invalidate only the user's own lower-AAL sessions — the browser/device sessions
that could have been upgraded but were not. A session issued to an OAuth client represents a
consented grant, not an authentication level the user can raise; nothing in the OAuth flow ever
upgrades it, so it is
aal1for its whole life. Its lifecycle should be governed by consentrevocation (
RevokeOAuthSessions), not by the user's MFA state in an unrelated app.Suggested fix, smallest change:
"DELETE FROM … WHERE user_id = ? AND aal < ? AND oauth_client_id IS NULL"Alternative: stamp OAuth client sessions with the AAL of the authorizing session at issuance (the
auth middleware already puts the approving session in context for the consent handler), so a grant
made from an
aal2browser session survives later step-ups.Screenshots
Not applicable — the evidence is the
auth.sessionsrow set before/after the verify and the/oauth/tokenresponse, both quoted above.System information
OAuth client's refresh path)
masteras of v2.197.0token_endpoint_auth_method: none)Additional context
lifecycle for client sessions — "when access is revoked, all active sessions and refresh tokens
for that client are immediately invalidated" — and neither that page nor the
MFA docs mention MFA verification affecting
them. The documented model matches the expected behaviour above; the DELETE does not.
oauth_client_idmissing from*models.Sessionduring refresh) — a different bug on the same column.invalidation semantics to change. This issue does: the semantics are wrong for OAuth client
sessions specifically. fix: map concurrent MFA verify races to 4xx instead of 500 #2791 is the pending fix for that one.
NULLaal rows; theoauth_client_id IS NULLclause suggested here composes with it.scope=globallogout (models.Logout, L358) has the same shape — it deletes every sessionincluding OAuth client ones, without touching
oauth_consents. That one is arguably intended("sign out everywhere"), but it means the consent record and the session it is supposed to back
can silently disagree. Mentioning it because both come from OAuth client sessions sharing
auth.sessionswith browser sessions with no lifecycle distinction in the delete paths.