Skip to content

Trusted-device MFA and impersonation audit trails on Supabase Auth

Supabase Auth has no setting for skipping the MFA prompt on a trusted device for N days, and its log stream does not show who impersonated whom. Both gaps were measured on a live project. Each part below gives the native behaviour, where it stops, and the custom piece that closes the gap.

Prerequisites: a Supabase Pro (or higher) project with MFA enabled, and a backend that validates Supabase JWTs locally against the project JSON Web Key Set (JWKS, the /.well-known/jwks.json endpoint), which is the bring-your-own (BYO) backend pattern. The impersonation half also needs an imported asymmetric signing key, so your backend can mint its own Supabase-shaped tokens.

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. The workarounds in this guide change when that claim is demanded; MFA itself stays as it is. In 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.

fresh login(password / SSO)aal1 sessiontrusted-devicecookie + server row?TOTP challengeabsent / expiredaal1 session, trusted(backend gate checks the cookie)valid + not expired(skip challenge)aal2 sessionverifiedsensitive action(force aal2 always)

The same flow, as text:

  1. A fresh login (password or SSO) gives an aal1 session.
  2. Check for a trusted-device cookie plus its server row. Valid and not expired: skip the challenge; the session stays aal1, and your backend gate, which sees the cookie, lets it through. Absent or expired: go to 3.
  3. Run the TOTP challenge. Verified: aal2 session.
  4. A sensitive action always forces aal2, trusted device or not.
BehaviourResult (live)
aal2 after TOTP verify, then refreshSession() x2stays aal2
Fresh signInWithPassword on a new client (a new device)aal1, nextLevel=aal2, factor still verified
  • On the same device, with the session kept alive, the user is not prompted again: a refreshed session is never re-challenged and keeps aal2 for its whole lifetime. The re-prompt interval on one device is therefore the session TTL, set in Auth settings as Time-box user sessions (a hard cap) and Inactivity timeout. To stop asking for a day on one device, give the session a roughly one-day inactivity timeout and do not force logout.
  • The refresh result above was measured on the project that issued the session. After a user’s auth rows are copied to another project, the first refresh at the new project keeps aal2 only if auth.mfa_amr_claims was copied too; without it the session came back aal1 with no amr claim (session-carry S04a, S04b, 2026-09-25).
  • A new device or a fresh login starts a new session at aal1, which must challenge again.

Supabase Auth has no Google-style “trust this device, don’t ask for 30 days even after logout or on a new session”. It keeps no device-trust registry, and the MFA Verification Hook runs only after a code is submitted: it can rate-limit or reject a challenge but cannot skip one.

Build device trust in your app layer on top of the native aal:

  1. 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=Strict cookie.
  2. 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 stays aal1 (measured: a fresh login is aal1 even with a verified factor), so satisfy your own gate in the backend, where the cookie is visible. A query under an RLS policy that gates on auth.jwt()->>'aal' = 'aal2' is denied on this path, and the app has to run the TOTP challenge before that action succeeds.
  3. 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 aal2 there 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.

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. Signing it is local crypto, so it produces no audit event anywhere and is not a session: getUser, refresh, and signout all return session_not_found (measured). It is a bearer for your own JWKS validation and nothing else.
  • The native auth audit log records login/logout/token/MFA events, but a fresh hosted project has audit_log_disable_postgres: true, so the SQL-queryable auth.audit_log_entries table is empty by default (measured: select count(*) -> 0 after real logins). This contradicts the audit-logs docs, which say entries are stored in two places. The toggle is Dashboard-only (Authentication -> Configuration -> Audit Logs), and the Management API PATCH is a no-op. The 2026-09-08 re-check on two fresh projects confirmed the true default and the no-op PATCH. The Dashboard labels the switch in the positive form, “Write audit logs to the database”, where these docs describe the negative one; the detail is in the audit-trail integrity reference.
  • Regardless of that toggle, the log stream (the logs backend / Log Drains source) captures every login as an auth_event with action, actor_id, actor_username, provider, remote_addr, status, and time. This is the stream an external SIEM (for example Datadog via Log Drains) consumes. The query shape on the replacement logs endpoint (WHERE source = 'auth_logs', fields read from the log_attributes map) is in the logs-endpoint guide; that endpoint is the one the 2026-08-22 re-check used.

For 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. Treat a failed insert as a failed impersonation, since nothing else will record it. Carry act.sub = adminId in the token so every downstream request is attributable via JWKS. This row is the only record of which admin acted; the native log shows the target’s own login and nothing about the actor. Short-lived tokens (10 minutes) bound the window.

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, which a self-minted bearer cannot do.

// 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 live: the exchange yields a real session (getUser, refresh, and signout all work; amr:[otp]; carries a session_id), and the log stream captures the whole sequence 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).

Measured: the login event’s actor_id is the target, and the admin calls log as service_role, so the stream never names the human admin behind the impersonation. Pair this with a one-row insert into your own audit table (as in workaround A) for actor attribution, and restrict who can call the endpoint: anyone who can call it can mint a session as any user.

  • Turn on the Postgres audit table (Dashboard) or wire Log Drains to a SIEM so auth_events are retained and queryable. Do not assume auth.audit_log_entries is populated. After turning the toggle on, confirm with select count(*) from auth.audit_log_entries after one login before relying on it: the default-off state was measured (0 after 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.
ClaimStatusHow it was checked
aal2 survives refresh; a fresh login is aal1 even with a verified factormeasuredrefreshSession() x2 after TOTP verify, and a fresh signInWithPassword on a new client, live in ap-southeast-2 on 2026-07-24; same-project refreshes only
A self-minted signing-key JWT produces no audit event and is not a sessionmeasuredgetUser, 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)measuredselect 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-writablemeasuredthe 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_eventmeasuredread on analytics/endpoints/logs.all on the live project (2026-07-24) and re-verified on the replacement analytics/endpoints/logs with WHERE source = 'auth_logs' (2026-08-22). logs.all was removed on 2026-09-23 and answered HTTP 410 when probed on 2026-10-10, so a reproduction today must use the replacement, as in the logs-endpoint guide. The footnote here1 is the migration announcement, published 2026-07-23, two months before the removal; the measured claim itself is unchanged
After a cross-project copy of auth rows, aal2 survives the first refresh at the new project only if auth.mfa_amr_claims was copiedmeasuredsession-carry S04a, S04b, 2026-09-25, two Micro projects in ap-southeast-1: aal1 and no amr without it, aal2 with amr password+totp with it; out/2026-09-25
Audit entries are stored in two placesdocumentedaudit-logs docs - contradicted by the live check above
PracticeEvidence
Put the trusted-device gate in the backend instead of an RLS predicate on auth.jwt()->>'aal' = 'aal2'The backend can see the cookie; a skipped challenge does not satisfy the predicate. 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)Evidence row 1: aal2 survives refreshSession() x2; a live session is never re-challenged
Never trust a device for a step-up action; force aal2 there regardless of the cookieDesign 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 impersonationEvidence 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 itEvidence 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 topEvidence 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

Every check here reproduces on a throwaway Pro project with the Auth client calls in the Part 1 table plus the Management API, config, and log-stream calls below:

Terminal window
# 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 was removed on 2026-09-23 and answers 410, probed 2026-10-10; use /analytics/endpoints/logs with
# `where source = 'auth_logs'`. That endpoint is ClickHouse-only, GET-only, and filters by the `source` column -
# the changelog example filters by `source_name`, but `source` is the column that worked in the runs here)
# ALWAYS tear the project down afterwards (it is billable).

No project files ship with this guide. The two behaviour suites ran as throwaway probe scripts against the scratch project and were not retained; every check reduces to the calls listed in Verification.

#Finding
1aal2 survives token refresh on the issuing project (2026-07-24), so session TTL is the native re-prompt interval; after a cross-project copy, see the auth.mfa_amr_claims bullet under the flow.
2A fresh login is aal1 (nextLevel=aal2) even with a verified factor, so every new device re-prompts.
3Trusted-device “remember for N days” is a custom cookie plus server row around native aal; the MFA Verification Hook cannot skip a challenge.
4A self-minted signing-key JWT is not a session and is never audited (session_not_found; no log event).
5auth.audit_log_entries is empty by default (audit_log_disable_postgres: true), and the toggle is Dashboard-only; the docs say otherwise.
6The log stream always carries auth_events (login/logout/token/generate_link/signup, with actor_id, log_type, path, remote_addr); Log Drains to a SIEM is how you see impersonation in it.
7generateLink({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 the human admin is not attributed without a custom row.
8Native logs never record the admin actor behind an impersonation; a custom act-claim plus audit row is the only source of that.
  1. Supabase, “Migration of Supabase Management API logs.all analytics endpoint to logs endpoint,” Supabase Changelog, 2026-07-23. https://supabase.com/changelog/48235-migration-of-supabase-management-api-logs-all-analytics-endpoint-to-logs-endpoint ↩