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.
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. 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.
The same flow, as text:
- A fresh login (password or SSO) gives an
aal1session. - 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. - Run the TOTP challenge. Verified:
aal2session. - A sensitive action always forces
aal2, trusted device or not.
Part 1: trusted-device MFA skip
Section titled “Part 1: trusted-device MFA skip”What is native
Section titled “What is native”| 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 |
- On the same device, with the session kept alive, the user is not prompted again: a refreshed session is never re-challenged and keeps
aal2for 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
aal2only ifauth.mfa_amr_claimswas copied too; without it the session came backaal1with noamrclaim (session-carry S04a, S04b, 2026-09-25). - A new device or a fresh login starts a new session at
aal1, which must challenge again.
What is not native
Section titled “What is not native”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.
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), so satisfy your own gate in the backend, where the cookie is visible. A query under an RLS policy that gates onauth.jwt()->>'aal' = 'aal2'is denied on this path, and the app 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.
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. Signing it is local crypto, so it produces no audit event anywhere and is not a session:
getUser, refresh, and signout all returnsession_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-queryableauth.audit_log_entriestable is empty by default (measured:select count(*) -> 0after 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 thetruedefault 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_eventwithaction,actor_id,actor_username,provider,remote_addr,status, andtime. 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 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”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.
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, 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.
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; same-project refreshes only |
| 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 | read 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 copied | measured | session-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 places | documented | audit-logs docs - contradicted by the live check above |
What to do about it
Section titled “What to do about it”| Practice | Evidence |
|---|---|
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 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 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:
# 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).File reference
Section titled “File reference”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.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”| # | Finding |
|---|---|
| 1 | aal2 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. |
| 2 | A fresh login is aal1 (nextLevel=aal2) even with a verified factor, so every new device re-prompts. |
| 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). |
| 5 | auth.audit_log_entries is empty by default (audit_log_disable_postgres: true), and the toggle is Dashboard-only; 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); Log Drains to a SIEM is how you see impersonation in it. |
| 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 the human admin is not attributed 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. |
References
Section titled “References”-
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 ↩