Skip to content

OAuth 2.1 server: MFA verification deletes OAuth client sessions (InvalidateSessionsWithAALLessThan does not exclude oauth_client_id) #2801

Closed

Description

@cgeorgakopoulos-sleed

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

  1. User has a verified TOTP factor.
  2. User authorizes an OAuth 2.1 client via /oauth/authorize → auth.sessions gets a row with
    oauth_client_id set and aal = 'aal1'.
  3. Same user signs in to a first-party app (session aal1) and completes POST /factors/{id}/verify.
  4. 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.
  5. 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

Activity

  1. breken-ai commented on Sep 13, 2026

    @breken-ai

    Confirmed independently against current master (4eee58f) - the trace checks out at every step:

    • All four verify paths call models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()): verifyTOTPFactor (mfa.go:701), verifyPhoneFactor (mfa.go:842), verifyWebAuthnFactor (mfa.go:962), and RecoveryCodesVerify (recovery_codes.go:430).
    • The function (sessions.go:353) is exactly DELETE FROM ... WHERE user_id = ? AND aal < ? - no oauth_client_id exclusion, so any MFA step-up wipes every session below aal2 for that user.
    • OAuth 2.1 client sessions are born aal1 and stay there: models.NewSession (sessions.go:261) hard-codes AAL: AAL1.PointerString(), and the authorization-code flow never gives the client a step-up opportunity.
    • The model already distinguishes these sessions (Session.OAuthClientID, sessions.go:96) and already has their dedicated lifecycle (RevokeOAuthSessions, sessions.go:373, keyed on user_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: RecoveryCodesVerify joined 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 NULL on 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 to RevokeOAuthSessions, 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.

  2. jhurleyai commented on Sep 28, 2026

    @jhurleyai

    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.

  3. baptistesx commented on Sep 30, 2026

    @baptistesx

    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 runs InvalidateSessionsWithAALLessThan and silently logs the same user out of the mobile app; the next app launch gets refresh_token_not_found.

    Excluding oauth_client_id sessions (#2802) would not cover this case. Would you consider making the invalidation configurable, e.g. a GOTRUE_MFA_INVALIDATE_LOWER_AAL_SESSIONS=false flag (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 checking aal2 keep protecting the privileged data.

  4. kut237 commented on Oct 9, 2026

    @kut237

    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_logs for one user:

    • POST /factors/{id}/verify 200 (step-up in the web console)
    • ~48 min later the MCP client's POST /oauth/token (grant_type=refresh_token) → 400 refresh_token_not_found
    • the client's OAuth session (created ~12 min before the verify, aal1, oauth_client_id set) is gone from auth.sessions; the user's aal2 browser session is untouched

    So 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 NULL sessions from InvalidateSessionsWithAALLessThan or stamping OAuth sessions with the AAL of the authorizing session.

  5. AkshatSoni26 commented on Oct 13, 2026

    @AkshatSoni26

    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:

    1. Connector authorized via DCR + /oauth/authorize (PKCE) + consent + /oauth/token → auth.sessions row with oauth_client_id set and aal = 'aal1' (even though the consent was given from an aal2 browser session). grant_type=refresh_token works.
    2. Same user signs in to the web app with a password in a separate browser → connector refresh still works.
    3. The user completes POST /factors/{id}/verify (TOTP) in that browser → the connector's next grant_type=refresh_token returns 400 refresh_token_not_found, and its auth.sessions row is gone. Only the aal2 browser 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_id sessions from InvalidateSessionsWithAALLessThan) 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 to scope: 'local', but that's on our side.)

  6. xavierpeich commented on Nov 3, 2026

    @xavierpeich

    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:

    1. Sign in, enroll TOTP, verify: the portal session is aal2.
    2. Approve an OAuth client from that aal2 session. The client's session is created aal1 (token claim and auth.sessions.aal).
    3. Verify a new TOTP code in the portal. The client's session row is gone, and its next refresh returns 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 claiming aal1, these all succeeded, because the checks read the session row:

    • PUT /user with a new password: 200
    • POST /factors (enroll another TOTP): 200
    • DELETE /factors/{id} on the verified factor: 200

    At aal1 the same three calls are refused with insufficient_aal. Since OAuth client tokens can reach /user and /factors (#2877), raising their AAL hands a third-party client the power to set a password and remove MFA. That makes the #2802 approach (leave oauth_client_id IS NOT NULL sessions out of InvalidateSessionsWithAALLessThan) 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?

  7. cemalkilic commented on Nov 3, 2026

    @cemalkilic
    Contributor

    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 an aal2 session (#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.aal yourself, 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions