Skip to content

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

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/tokenIssueRefreshTokenmodels.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/authorizeauth.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

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