Repository navigation
Description
Bug report
- I confirm this is a bug with Supabase, not with my own application.
- I confirm I have searched the Docs, GitHub Discussions, and Discord.
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.sessionsgets a row with
oauth_client_idset andaal = 'aal1'. - Same user signs in to a first-party app (session
aal1) and completesPOST /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 verifiedaal2browser session remains.- The client's next
grant_type=refresh_tokento/oauth/tokenreturns
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
masteras of v2.197.0 - OAuth 2.1 server enabled; public client (
token_endpoint_auth_method: none) - MFA: TOTP
Additional context
- The OAuth 2.1 Server docs describe one
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. - Nearest prior discussion is Bug Report: missing destination name oauth_client_id in *models.Session during token refresh supabase#40036 (
oauth_client_idmissing from
*models.Sessionduring refresh) — a different bug on the same column. - Concurrent MFA verify for the same user returns 500 (FK violation / deadlock) instead of a 4xx conflict #2727 reports a concurrency failure of the same DELETE and explicitly does not ask for the
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. - fix(sessions): invalidate legacy sessions with null aal #2792 (open) edits the same query to also match legacy
NULLaal rows; theoauth_client_id IS NULLclause suggested here composes with it. scope=globallogout (models.Logout, L358) has the same shape — it deletes every session
including OAuth client ones, without touchingoauth_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.
Activity
breken-ai commentedon Sep 13, 2026
Confirmed independently against current master (4eee58f) - the trace checks out at every step:
models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()):verifyTOTPFactor(mfa.go:701),verifyPhoneFactor(mfa.go:842),verifyWebAuthnFactor(mfa.go:962), andRecoveryCodesVerify(recovery_codes.go:430).DELETE FROM ... WHERE user_id = ? AND aal < ?- nooauth_client_idexclusion, so any MFA step-up wipes every session below aal2 for that user.models.NewSession(sessions.go:261) hard-codesAAL: AAL1.PointerString(), and the authorization-code flow never gives the client a step-up opportunity.Session.OAuthClientID, sessions.go:96) and already has their dedicated lifecycle (RevokeOAuthSessions, sessions.go:373, keyed onuser_id AND oauth_client_id) - the MFA delete path just ignores the distinction.So the mechanism is confirmed: any MFA verification in any first-party app deletes all of the user's OAuth client sessions, and the client's next refresh hits
refresh_token_not_found. Worth calling out the blast radius:RecoveryCodesVerifyjoined the other three paths in #2764 (merged Sep 2), so since then a recovery-code login - precisely the flow a user takes when they lose their factor - also wipes their OAuth client sessions.Fix direction: the smallest correct change is the one proposed here -
AND oauth_client_id IS NULLon the DELETE (it composes cleanly with #2792, which edits the same query). Structurally, the deeper point stands either way: OAuth client sessions represent consent, not an upgradeable authentication level, so their lifecycle belongs toRevokeOAuthSessions, not to the user's MFA state. The alternative of stamping the authorizing session's AAL at issuance would also work but leaves aal1 grants from aal1 sessions exposed to the same wipe.Nice work on the trace - line numbers, repro, and the before/after row evidence made this quick to confirm.
Built by breken, your AI support engineer - breken.ai finds the bug, proves the fix with unmatched RCA, answers customers in your voice, and keeps your code and support docs clean - this one's on us.
jhurleyai commentedon Sep 28, 2026
We hit this too, with a Claude Code MCP client on an MFA-enforced project. Consultants would lose their MCP connection every time they verify TOTP in our admin app. A related gap is that OAuth tokens can never be aal2 at all, so they can't be accepted where MFA is required. The "stamp OAuth client sessions with the AAL of the authorizing session" option would fix both.
baptistesx commentedon Sep 30, 2026
We hit the same root cause with a first-party setup, not OAuth: a native mobile app (supabase_flutter, never steps up, so its sessions stay
aal1) and a web admin console on the same project, where privileged users must verify TOTP every few hours. Each TOTP verification in the web console runsInvalidateSessionsWithAALLessThanand silently logs the same user out of the mobile app; the next app launch getsrefresh_token_not_found.Excluding
oauth_client_idsessions (#2802) would not cover this case. Would you consider making the invalidation configurable, e.g. aGOTRUE_MFA_INVALIDATE_LOWER_AAL_SESSIONS=falseflag (defaulting to today's behaviour), or scoping it to the session that performed the verification? That would let projects where step-up is required on one client only keep other clients' sessions alive, while RLS policies checkingaal2keep protecting the privileged data.kut237 commentedon Oct 9, 2026
Same here, measured on a hosted project (Oct 2026): an MCP server protected by the Supabase OAuth 2.1 server, used as a Claude connector, plus a web admin console that requires a TOTP step-up for sensitive actions.
Timeline from
auth_logsfor one user:POST /factors/{id}/verify200 (step-up in the web console)POST /oauth/token(grant_type=refresh_token) → 400refresh_token_not_foundaal1,oauth_client_idset) is gone fromauth.sessions; the user'saal2browser session is untouchedSo every MFA login or step-up silently disconnects all of the user's AI/MCP integrations, which makes MFA and the OAuth server hard to combine for any multi-tenant app. +1 for either excluding
oauth_client_id IS NOT NULLsessions fromInvalidateSessionsWithAALLessThanor stamping OAuth sessions with the AAL of the authorizing session.AkshatSoni26 commentedon Oct 13, 2026
We hit this on a hosted project too (2026-10-13), with the same shape as the reports above: an MCP server behind the Supabase OAuth 2.1 server, used as a Claude / ChatGPT connector, and a first-party web app where TOTP is mandatory for every user.
Reproduced end to end against the hosted project:
/oauth/authorize(PKCE) + consent +/oauth/token→auth.sessionsrow withoauth_client_idset andaal = 'aal1'(even though the consent was given from anaal2browser session).grant_type=refresh_tokenworks.POST /factors/{id}/verify(TOTP) in that browser → the connector's nextgrant_type=refresh_tokenreturns400 refresh_token_not_found, and itsauth.sessionsrow is gone. Only theaal2browser sessions remain.Because MFA is mandatory for our users, every sign-in to the web app disconnects their AI assistant, and they have to re-authorize the connector each time.
#2802 (exclude
oauth_client_idsessions fromInvalidateSessionsWithAALLessThan) fixes exactly this for us. Could a maintainer take a look at it? Stamping OAuth sessions with the authorizing session's AAL (as suggested above) would be a welcome follow-up, but #2802 alone removes the user-facing breakage.(Separately, and not a Supabase issue: a default
signOut()is global and also deletes OAuth client sessions. We switched our web sign-out toscope: 'local', but that's on our side.)xavierpeich commentedon Nov 3, 2026
Same here on a hosted project (GoTrue v2.197.0, measured on 2026-11-03). We run an MCP server behind the OAuth 2.1 server, used as a Claude and ChatGPT connector, and a web portal where people can turn on TOTP. Each portal sign-in with a code deletes every connector session that user has. One customer reconnected Claude 8 times in 13 days before writing to us, and it hits every user with TOTP on.
We reproduced it end to end with a throwaway user:
aal2.aal2session. The client's session is createdaal1(token claim andauth.sessions.aal).refresh_token_not_found.One warning for anyone looking for a workaround, and it bears on #2869: we tried setting
auth.sessions.aal = 'aal2'on the client's session (what a trigger would do). The session then survives step 3, but the client's token can also change the user's sign-in. With the access token still claimingaal1, these all succeeded, because the checks read the session row:PUT /userwith a new password: 200POST /factors(enroll another TOTP): 200DELETE /factors/{id}on the verified factor: 200At
aal1the same three calls are refused withinsufficient_aal. Since OAuth client tokens can reach/userand/factors(#2877), raising their AAL hands a third-party client the power to set a password and remove MFA. That makes the #2802 approach (leaveoauth_client_id IS NOT NULLsessions out ofInvalidateSessionsWithAALLessThan) look like the safe fix to us, at least until #2877 is closed.Is #2802 likely to be merged and rolled out to hosted projects soon?
cemalkilic commentedon Nov 3, 2026
Sorry for the radio silence here, it took us far too long to respond, especially with so many of you hitting it in production. Thanks everyone for the detailed reports and repros, and thanks @melcheikh for the fix in #2802.
I'm going to merge #2802. After it lands, completing MFA (TOTP, phone, WebAuthn, or a recovery code) will no longer delete sessions that were issued to OAuth clients. Those sessions still end when the user revokes the client's grant, signs out globally, or changes their password. The change will reach hosted projects with a regular Auth release after the merge, likely next week.
This PR intentionally does not change the AAL of OAuth client tokens. They are still
aal1, even when the user approved the client from anaal2session (#2838 / #2869). The OAuth 2.1 server is still in beta, and getting the AAL/MFA story right is definitely something we want to address before making it GA. We want to look at it as a whole rather than piece by piece. As @xavierpeich showed, raising the AAL of an OAuth session changes what that client's token is allowed to do, so it needs to be designed together with which endpoints accept OAuth tokens (tracked separately in #2877). Timelines are hard to give, but we're aiming for early 2027.Until then, please don't change
auth.sessions.aalyourself, by hand or with a trigger, as a workaround. It gives the OAuth clients more power over the account than the user approved.@baptistesx, your case is a bit different: those are first-party sessions, and invalidating lower-AAL sessions on MFA verification is the intended behaviour there today. Making that configurable is a reasonable request, but it's separate from this bug. Could you open a new issue for it so it doesn't get lost when this one closes?
fix(models): exclude oauth client sessions from mfa step-up invalidat…