perf(auth): skip the MFA factor lookup once the session is at AAL2 (#1933)

The enforced-MFA branch of the proxy called supabase.auth.mfa.listFactors()
on every page, RSC and prefetch request for every MFA-verified user with a
company. auth-js implements listFactors() as a getUser() network round
trip, so hosted page requests paid two Supabase Auth calls in sequence.

At AAL2 a verified factor exists by construction (the session got there by
verifying a challenge on one), so the lookup only runs on the aal1/aal1
path (users mid-enrolment), where it still gates exactly as before. The one
case deferred is a user who unenrols their last factor mid-session: the
JWT keeps aal2 until the next refresh, so the enrolment bounce lands on the
refresh instead of the next click.

Tests: aal1/aal1 still asks for the factor list and bounces to /mfa/enroll;
at aal2 listFactors is never called (even with a factor list that would
read as empty) on page, RSC and prefetch requests; the step-up bounce and
the no-company skip are unchanged.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:15:22 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 47fe193c48
commit 52bfd7a399
2 changed files with 52 additions and 10 deletions
+21 -9
View File
@@ -379,16 +379,28 @@ async function updateSessionInner(
}
// MFA required but user has no factor enrolled yet → force enrollment
// Skip for users with no companies (still setting up)
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
if (companyIdForMfa) {
const { data: factors } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.listFactors(),
)
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
// Skip for users with no companies (still setting up).
//
// Only worth asking when the session is NOT at AAL2: reaching AAL2
// requires having verified a challenge on a verified factor, so the
// factor list cannot be empty there. auth-js implements listFactors()
// as a getUser() network round trip, and running it here on every
// page, RSC and prefetch request for every MFA-verified user was the
// second Supabase Auth call per request (measured via mw-mfa, PR #1922).
// The narrow case this defers is a user who unenrols their last factor
// mid-session: the JWT keeps aal2 until the next token refresh, so the
// enrolment bounce lands on the refresh instead of the next click.
if (aal?.currentLevel !== 'aal2') {
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
if (companyIdForMfa) {
const { data: factors } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.listFactors(),
)
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
if (!hasVerifiedFactor) {
return bounceToAuth(request, '/mfa/enroll')
if (!hasVerifiedFactor) {
return bounceToAuth(request, '/mfa/enroll')
}
}
}
}