Trusted-device MFA and impersonation audit trails on Supabase Auth
Two questions come up on almost every Supabase Auth evaluation with an enterprise security posture: “can we skip the MFA prompt on a trusted device for N days?” and “can we see who impersonated whom, from the log stream?” Neither is satisfied by a native toggle. This guide gives you, for each, the native behaviour, the honest not-native boundary, and the small custom piece that closes the gap.
Prerequisites: a Supabase Pro (or higher) project with MFA enabled, a backend that validates Supabase JWTs locally against the project JWKS (the BYO-backend pattern), and - for the impersonation half - an imported asymmetric signing key so your backend can mint its own Supabase-shaped tokens.
The assurance model you build on
Section titled “The assurance model you build on”MFA in Supabase is expressed through the Authenticator Assurance Level (aal) claim on the JWT. A conventional login is aal1; verifying a second factor (TOTP or phone) upgrades the session to aal2. You gate sensitive actions on aal2, either in the database with an RLS predicate (auth.jwt()->>'aal' = 'aal2') or with a backend check. Everything in this guide sits on top of that single claim - you are not replacing MFA, you are shaping when it is demanded.
Part 1: Trusted-device MFA skip
Section titled “Part 1: Trusted-device MFA skip”What is native
Section titled “What is native”Two measured facts settle the “how often do we re-prompt” question:
| Behaviour | Result (live) |
|---|---|
aal2 after TOTP verify, then refreshSession() x2 | stays aal2 |
Fresh signInWithPassword on a new client (a new device) | aal1, nextLevel=aal2, factor still verified |
So there are two regimes:
- Same device, session kept alive -> no re-prompt. A refreshed session is never re-challenged;
aal2rides along for the whole session lifetime. The knob that controls “how long before we ask again on this device” is therefore the session TTL, set in Auth settings: Time-box user sessions (a hard cap) and Inactivity timeout. Want “don’t ask again for a day on this device”? Give the session a roughly one-day inactivity timeout and do not force logout. - Every new device or fresh login re-prompts. A brand-new session starts at
aal1and must challenge again.
What is not native
Section titled “What is not native”The Google-style “trust this device, don’t ask for 30 days even after logout or on a new session.” GoTrue keeps no device-trust registry, and the MFA Verification Hook only runs after a code is submitted - it can rate-limit or reject a challenge, but it cannot skip one. There is no native “remember this device” flag.
The workaround (a few dozen lines)
Section titled “The workaround (a few dozen lines)”Build device trust in your app layer on top of the native aal:
- After a successful
aal2, compute a device token (random, or a fingerprint bound to the user), store{user_id, device_hash, trusted_until}server-side, and set a signed,HttpOnly,Secure,SameSite=Strictcookie. - On a subsequent fresh login, before presenting the TOTP challenge, check the cookie plus the server row. If present and
trusted_until > now(), skip the challenge step for that login (proceed ataal1, or auto-treat your own gate as satisfied). - Expire and rotate on the server; offer “forget this device”; and never trust a device for a genuine step-up action (address or payment change) - force
aal2there regardless of any trusted-device cookie.
This is a deliberate, documented weakening of MFA - the same trade-off every IdP makes with a “remember this device” checkbox. Keep it off high-value actions.
Part 2: Impersonation audit trail
Section titled “Part 2: Impersonation audit trail”In a BYO-backend Supabase setup, impersonation is your backend minting a short-lived JWT as the target user, signed with an imported signing key, carrying an act (RFC 8693 actor) claim set to the admin id. The backend validates it via JWKS like any other token.
What is native (and the trap)
Section titled “What is native (and the trap)”- A self-minted signing-key JWT never touches GoTrue - it is local crypto. So it produces no audit event anywhere, and it is not a session:
getUser, refresh, and signout all returnsession_not_found(verified). It is a pure bearer for your own JWKS validation. - The native auth audit log records login/logout/token/MFA events - but a live default bites here. On a fresh hosted project
audit_log_disable_postgres: true, so the SQL-queryableauth.audit_log_entriestable is empty by default (validated:select count(*) -> 0after real logins). This differs from the audit-logs docs, which say entries are stored in two places. The toggle is Dashboard-only (Authentication -> Configuration -> Audit Logs); it is not writable via the Management API (the PATCH is a no-op). - Regardless of that toggle, the log stream (the Logflare / Log Drains source) captures every login as an
auth_eventwithaction,actor_id,actor_username,provider,remote_addr,status, andtime. This is the stream an external SIEM (for example Datadog via Log Drains) consumes, and it is where “see it from the log stream” is actually satisfied.
Workaround A: custom act audit table (richest attribution)
Section titled “Workaround A: custom act audit table (richest attribution)”Recommended for real admin impersonation. Keep the self-minted-JWT approach and, at mint time, write your own impersonation_audit(actor, target, reason, issued_at, expires_at) row, and carry act.sub = adminId in the token so every downstream request is attributable via JWKS. This is the only path that records which admin acted - the native log only ever shows the target’s own login, never the actor behind an impersonation. Short-lived tokens (10 minutes) bound the window.
Workaround B: generateLink to a real, auditable session
Section titled “Workaround B: generateLink to a real, auditable session”Use this only when the impersonated session must work against the Supabase Data API, Storage, or Realtime and be refreshable - cases where a self-minted bearer is the wrong tool.
// admin (service_role), server-side only - no email is sent:const { data } = await admin.auth.admin.generateLink({ type: 'magiclink', email: targetEmail })const tokenHash = data.properties.hashed_token// exchange it for a REAL session for the target:const { data: s } = await anon.auth.verifyOtp({ token_hash: tokenHash, type: 'magiclink' })// s.session is a genuine, refreshable, sign-out-able session (validated).Validated: this yields a real session (getUser, refresh, and signout all work; amr:[otp]; carries a session_id), and the whole sequence is captured in the log stream as auth_events - the impersonation login (/verify, log_type:account), the generateLink call (user_recovery_requested, /admin/generate_link), and the createUser (user_signedup, actor_username:service_role).
The trade-off (proven): the login event’s actor_id is the target, and the admin calls log as service_role - the stream never names the human admin behind the impersonation. So pair this with a one-row insert into your own audit table (as in workaround A) for actor attribution. And protect the endpoint hard: anyone who can call it can mint a session as any user.
Recommended combination
Section titled “Recommended combination”- Turn on the Postgres audit table (Dashboard) or wire Log Drains to a SIEM so
auth_events are retained and queryable. Do not assumeauth.audit_log_entriesis populated. - Use workaround A (self-minted JWT +
act+ custom audit row) for admin impersonation of a BYO-backend - it is the only source of admin-actor attribution and needs no session. - Use workaround B only when the impersonated identity must drive Supabase-native services and be refreshable; add the custom audit row on top for the actor.
Verification
Section titled “Verification”The two behaviour suites are reproducible against any throwaway Pro project:
# provision a throwaway Pro project, then:export SB_URL=https://<ref>.supabase.co SB_ANON=<anon> SB_SERVICE=<service_role># give a freshly-provisioned project ~45s of auth warm-up first, or generateLink's# token verify can transiently return "Email link is invalid or has expired".node test-mfa.mjs # aal persistence matrixnode test-audit.mjs # generateLink real-session + impersonation hop# audit table / config / log stream checks are Management API SQL + config/auth + analytics/endpoints/logs.all# ALWAYS tear the project down afterwards (it is billable).Gotchas and lessons learned
Section titled “Gotchas and lessons learned”| # | Finding |
|---|---|
| 1 | aal2 survives token refresh - a live session is never re-challenged; session TTL is the native “re-prompt interval”. |
| 2 | A fresh login is aal1 (nextLevel=aal2) even with a verified factor - every new device re-prompts. No native trusted-device skip. |
| 3 | Trusted-device “remember for N days” is a custom cookie plus server row around native aal; the MFA Verification Hook cannot skip a challenge. |
| 4 | A self-minted signing-key JWT is not a session and is never audited (session_not_found; no log event) - it is a JWKS bearer only. |
| 5 | auth.audit_log_entries is empty by default (audit_log_disable_postgres: true); the toggle is Dashboard-only, not Management-API-writable. The docs say otherwise. |
| 6 | The log stream always carries auth_events (login/logout/token/generate_link/signup, with actor_id, log_type, path, remote_addr) - the real answer to “see it from the log stream” (Log Drains to a SIEM). |
| 7 | generateLink({magiclink}) + verifyOtp(token_hash) mints a real, refreshable session for a target and the /verify login is logged - but actor_id is the target (admin calls log as service_role), so no human-admin attribution without a custom row. |
| 8 | Native logs never record the admin actor behind an impersonation; a custom act-claim plus audit row is the only source of that. |