Trusted-device MFA and impersonation audit trails on Supabase Auth
Two gaps in Supabase Auth have no setting to find: skipping the MFA prompt on a trusted device for N days, and seeing who impersonated whom from the log stream. This guide measured both gaps live and builds the custom pieces that close them - for each, the native behavior, where it stops, and the workaround.
Prerequisites: a Supabase Pro (or higher) project with MFA enabled, a backend that validates Supabase JWTs locally against the project JSON Web Key Set (JWKS, the /.well-known/jwks.json endpoint) - the bring-your-own (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. One consequence for the trusted-device pattern in Part 1: a skipped challenge leaves the session at aal1, and only GoTrue’s own verify step raises it to aal2, so a database-side aal2 predicate cannot be satisfied by that path. Gate in the database for the actions that must always challenge, and in the backend, where the trusted-device cookie is visible, for the ones a trusted device may skip.
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:
| Behavior | 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.” Supabase Auth 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. The session staysaal1(measured: a fresh login isaal1even with a verified factor, and only GoTrue’s verify raises it), so satisfy your own gate in the backend where the cookie is visible; any RLS policy that gates onauth.jwt()->>'aal' = 'aal2'is unreachable on this path: the query is denied, and your app still has to run the TOTP challenge before that action succeeds. - 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 empty-by-default audit table)
Section titled “What is native (and the empty-by-default audit table)”- A self-minted signing-key JWT never touches Supabase Auth - 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). Re-verified 2026-09-08 on two fresh projects, and the Dashboard labels the switch in the positive form, “Write audit logs to the database”, where these docs describe the negative one - see the audit-trail integrity reference. - 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. The query shape on the replacement logs endpoint (WHERE source = 'auth_logs', fields read from thelog_attributesmap) is in the logs-endpoint guide; that endpoint is the one the 2026-08-22 re-check used.
Workaround A: custom act audit table
Section titled “Workaround A: custom act audit table”Recommended for real admin impersonation. Keep the self-minted-JWT approach and, in the same request and before minting, write your own impersonation_audit(actor, target, reason, issued_at, expires_at) row - a failed insert is a failed impersonation, since nothing else will record it - 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. After turning the toggle on, confirm withselect count(*) from auth.audit_log_entriesafter one login before relying on it: the default-off state was measured (0after real logins), the on state was not. - 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.
Evidence
Section titled “Evidence”| Claim | Status | How it was checked |
|---|---|---|
aal2 survives refresh; a fresh login is aal1 even with a verified factor | measured | refreshSession() x2 after TOTP verify, and a fresh signInWithPassword on a new client, live in ap-southeast-2 on 2026-07-24 |
| A self-minted signing-key JWT produces no audit event and is not a session | measured | getUser, refresh, and signout all returned session_not_found on the live project |
auth.audit_log_entries is empty by default (audit_log_disable_postgres: true) | measured | select count(*) -> 0 after real logins, via Management API SQL. Re-verified 2026-09-08 on two fresh projects in ap-southeast-1 (audit-integrity A06c: 3 users and 3 password logins, table 0 -> 0) |
| The Postgres-audit toggle is Dashboard-only, not Management-API-writable | measured | the PATCH was a no-op on a live project. Re-verified 2026-09-08 by asking for the OPPOSITE of the held value: HTTP 200, value unchanged (audit-integrity A06b) |
The log stream captures every login as an auth_event | measured | analytics/endpoints/logs.all on the live project (2026-07-24); that endpoint is removed on 2026-09-23, and the same check was re-verified on the replacement analytics/endpoints/logs with WHERE source = 'auth_logs' (2026-08-22) |
| Audit entries are stored in two places | documented | audit-logs docs - contradicted by the live check above |
What to do about it
Section titled “What to do about it”| Practice | Rests on |
|---|---|
Put the trusted-device gate in the backend, where the cookie is visible; an RLS predicate on auth.jwt()->>'aal' = 'aal2' cannot be satisfied by a skipped challenge | Evidence row 1: a fresh login is aal1 even with a verified factor, and only GoTrue’s verify raises it |
| Set the re-prompt interval with the session TTL (time-box and inactivity timeout); a live session is never re-challenged | Evidence row 1: aal2 survives refreshSession() x2 |
Never trust a device for a step-up action; force aal2 there regardless of the cookie | Design choice, not a result: the same trade-off every IdP makes with “remember this device” |
Write the impersonation_audit row before minting the token, in the same request, and treat a failed insert as a failed impersonation | Evidence row 2: a self-minted JWT produces no audit event anywhere; Gotcha 8: native logs never name the admin actor |
After enabling the Postgres audit toggle, confirm with select count(*) from auth.audit_log_entries after one login before relying on it | Evidence row 3: 0 after real logins with the default audit_log_disable_postgres: true; the on state was not measured |
For a SIEM, read auth_events from Log Drains or from the logs endpoint with WHERE source = 'auth_logs', and add your own actor row on top | Evidence row 5: every login appears as an auth_event, re-verified 2026-08-22 on the replacement endpoint; Gotcha 7: actor_id is the target |
Verification
Section titled “Verification”Every check in this guide is reproducible against any throwaway Pro project using the Auth client calls in the Part 1 table plus the Management API, config, and log-stream calls below - the two behavior suites ran as throwaway probe scripts, not retained files:
# 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".# aal matrix: signInWithPassword + TOTP verify, then refreshSession() x2; fresh signInWithPassword on a new client (Part 1 table)# audit table / config / log stream checks are Management API SQL + config/auth + analytics/endpoints/logs# (logs.all is removed on 2026-09-23; the logs endpoint is ClickHouse-only, GET-only, and filters by# the `source` column - the official migration guide's `source_name` does not exist)# ALWAYS tear the project down afterwards (it is billable).File reference
Section titled “File reference”No project files ship with this guide. The two suites above ran as throwaway probe scripts against the scratch project and were not retained; every check reduces to the Auth client calls in the Part 1 table plus the Management API, config, and log-stream calls listed in Verification.
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. |