IT Infrastructure

Supabase Auth Deep Dive: RLS, Passkeys, and MFA in 2026

Supabase Auth architecture: Row Level Security policies, passkeys, and TOTP multi-factor authentication protecting a Postgres database

Supabase Auth is the authentication and authorization layer that ships with every Supabase project, and it does something most managed auth services do not: it pushes authorization all the way down into Postgres itself. Instead of gating access in application code, Supabase Auth issues a signed JWT, and the database reads that token to decide, row by row, what the caller is allowed to see. This post is an operational tour of how that works in 2026: the session and token model, Row Level Security (RLS), passkeys, and multi-factor authentication (MFA).

If you are new to the platform, our What Is Supabase? pillar covers the Postgres-backed backend-as-a-service that hosts all of this, and the Firebase comparison is worth reading if you are weighing the two platforms. Here we assume you already have a project and want to understand the auth stack in depth.

The session and token model

A session in Supabase Auth is two things: an access token, which is a JWT, and a refresh token, which is an opaque single-use string. Access tokens are short lived, typically 5 minutes to an hour, and they carry the claims the database and your APIs read: the user ID (sub), the role, and, after MFA, an assurance level. Refresh tokens never expire on their own but can be exchanged only once for a new access-and-refresh pair, a rotation scheme that limits the damage a stolen token can do.

The way those JWTs get signed changed meaningfully. Supabase now supports asymmetric JWT signing keys alongside the legacy shared secret. With an asymmetric key, a private key known only to Supabase Auth signs tokens, and a public key, published at a JSON Web Key Set (JWKS) discovery endpoint, verifies them. RS256 is the default; ECC and Ed25519 are options, with ECC offering better performance at the cost of narrower library support. The practical win is rotation: because verifiers fetch the public key from the JWKS endpoint, you can rotate or revoke a signing key without redeploying every service that checks tokens.

Row Level Security is the enforcement layer

Everything about Supabase authorization runs through Postgres Row Level Security. When you expose a table in the public schema, anyone holding your anon key can query it through the auto-generated REST and GraphQL APIs, so RLS is not optional; it is the thing standing between a published API key and your data. Enabling RLS on a table flips it to deny-all: until you write an explicit policy, nothing is readable or writable, which is safe but routinely surprises teams who see empty result sets right after turning it on.

Policies are SQL expressions evaluated per row. The canonical building block is auth.uid(), a helper that returns the user ID from the current request’s JWT. A user-scoped read policy is as simple as this:

create policy "Users read own rows"
  on documents for select
  to authenticated
  using ((select auth.uid()) = user_id);

From there, six patterns cover most applications: user-scoped access, multi-tenant SaaS (scoping by a tenant or organization ID), shared resources, role-based access, public-read with authenticated-write, and soft-delete visibility.

One security detail is easy to get wrong: read role and permission data from the JWT’s app_metadata, never from user_metadata. The user_metadata claim is editable by the authenticated end user, so any policy that trusts it can be bypassed by a user editing their own profile. Storing a role in app_metadata also lets a policy read it straight from the token instead of querying a roles table on every row.

Writing RLS policies that perform

RLS is powerful, but a careless policy can turn a fast query into a full-table crawl. Two rules do most of the work.

First, index every column a policy references. For a policy that filters on user_id, a btree index on that column can improve performance by more than 100x on large tables, per Supabase’s own RLS performance guide. Missing indexes are the single most common RLS performance killer.

Second, wrap function calls in a subquery. Writing (select auth.uid()) instead of a bare auth.uid() lets the Postgres optimizer run the function once as an initPlan and cache the result for the whole statement, rather than re-evaluating it for every row. The same trick applies to auth.jwt() and to security-definer helper functions. For complex checks that would otherwise force RLS to re-run against joined tables, a security-definer function runs with its creator’s privileges and can bypass the inner RLS, which is both a performance and a correctness tool. Use it deliberately, because bypassing RLS is exactly as dangerous as it sounds.

A testing note that saves hours: the SQL Editor in the dashboard runs as a privileged role and bypasses RLS entirely. Test policies through a client SDK with a real user token, not from the editor, or you will convince yourself a policy works when it does not.

Passkeys and WebAuthn

Supabase added passkey support built on the WebAuthn standard, letting users sign in with a device-bound credential unlocked by biometrics, a PIN, or a hardware key such as a YubiKey. Because the private key never leaves the device and the ceremony is bound to your domain, passkeys are phishing-resistant in a way passwords and even TOTP codes are not. That matters: credential phishing remains one of the most common attack vectors, as we cover in our guide to phishing attacks.

Two caveats before you build on it. Passkey support is experimental as of mid-2026, requires @supabase/supabase-js v2.105.0 or later, and you must opt in explicitly when creating the client (auth: { experimental: { passkey: true } }).

Mechanically, each registration or sign-in is a three-step WebAuthn ceremony: the client requests a challenge, the browser runs navigator.credentials.create() or .get() to prompt the user, and the signed response goes back to Supabase Auth for verification. Supabase uses discoverable credentials, so sign-in needs no email or username; the authenticator resolves the account. High-level registerPasskey() and signInWithPasskey() calls run the whole ceremony, and a lower-level auth.passkey namespace splits it into start and verify steps for native apps.

The configuration gotcha worth flagging in red: passkeys are cryptographically bound to the Relying Party (RP) ID, which is your bare domain. Change the RP ID after users have enrolled and every existing passkey becomes unusable, forcing everyone to register again. Pick it carefully and keep it stable. Anonymous and SSO users cannot register passkeys.

MFA with TOTP and phone factors

Supabase Auth implements MFA two ways: an app authenticator using a time-based one-time password (TOTP), and phone messaging using a code Supabase generates. TOTP MFA is free and enabled on every project by default. The flows split cleanly into enrollment (set up a factor) and challenge-and-verify (prove possession at sign-in), with a list-factors API for building management screens and an unenroll flow for removing lost devices.

The concept that ties MFA to authorization is the Authenticator Assurance Level (AAL), which follows the NIST 800-63B model. A session verified only by a conventional method (password, magic link, OAuth) is aal1. Add a verified second factor and the session becomes aal2. That level rides in the JWT’s aal claim, and a companion amr (Authentication Methods Reference) claim records which methods were used and when, most recent first.

Enforcing MFA where it counts

Adding an MFA screen to your UI does nothing for security on its own; you have to enforce the assurance level in the database. Because the aal claim lives in the JWT, RLS can read it directly. To require a second factor for every authenticated user:

create policy "Require MFA"
  on table_name
  as restrictive
  to authenticated
  using ((select auth.jwt()->>'aal') = 'aal2');

The as restrictive clause is load-bearing: restrictive policies apply on top of all other policies, so omitting it means a permissive policy elsewhere can grant the access you meant to block. Supabase documents three enforcement postures with matching policies: mandatory for all users, mandatory only for users created after a cutoff date, and mandatory only for users who opt in by enrolling a factor.

This database-level enforcement is exactly the posture zero-trust architecture calls for: never trust the client, verify every request at the resource. On the server-rendering side, treat an unexpected aal1 as a prompt to finish the MFA flow rather than an automatic 401, because a user with a stale open tab is a common and benign cause.

Common pitfalls

A short field guide to the mistakes that bite teams:

  • Leaving RLS off on a public-schema table. The anon key can read it, full stop.
  • Trusting user_metadata for roles or permissions. Move authorization data to app_metadata.
  • Testing policies in the SQL Editor, which bypasses RLS and hides broken rules.
  • Forgetting to index policy columns, or calling auth.uid() unwrapped on large tables.
  • Changing the passkey RP ID after enrollment and locking every user out.
  • Building an MFA UI without a restrictive aal2 policy behind it.

Get those six right and Supabase Auth gives you an authorization model that holds even when a bug in your app code would otherwise leak data, because the last line of defense lives in the database rather than the client.

Frequently Asked Questions

What is Supabase Auth?

Supabase Auth is the authentication and authorization service included in every Supabase project. It handles user sign-up and sign-in across password, magic link, OAuth social login, phone, SSO, and passkey methods, and it issues a signed JWT for each session. Its defining trait is that authorization is enforced in Postgres through Row Level Security, so the database itself decides what each authenticated user can read and write, rather than relying on application code to gate access.

Do I need Row Level Security if I use Supabase Auth?

Yes. Any table in the public schema is reachable through Supabase’s auto-generated REST and GraphQL APIs using the anon key, which is published in your client. Without RLS, that key can read and write the table freely. Enabling RLS flips a table to deny-all until you add explicit policies, making it the actual boundary between a public API key and your data. Authenticating a user does not restrict their data access on its own; the RLS policies do.

What is the difference between user_metadata and app_metadata in RLS?

Both are claims in the user’s JWT, but user_metadata is editable by the authenticated end user, while app_metadata is not. That distinction is critical for authorization: a policy that grants admin rights based on user_metadata can be bypassed by a user editing their own profile. Store roles, permissions, tenant IDs, and any value a policy trusts in app_metadata, which only server-side or admin code can change.

Does Supabase support passkeys?

Yes, through the WebAuthn standard, though passkey support is experimental as of mid-2026 and requires @supabase/supabase-js v2.105.0 or later plus an explicit opt-in when creating the client. Users sign in with a device-bound credential unlocked by biometrics, a PIN, or a hardware key, and the flow uses discoverable credentials so no email or username is needed. Passkeys are bound to a Relying Party ID (your bare domain), which must stay stable, because changing it invalidates every enrolled passkey.

How does multi-factor authentication work in Supabase?

Supabase Auth supports two second factors: an app authenticator using a time-based one-time password (TOTP) and phone messaging with a generated code. TOTP MFA is free and on by default. Users enroll a factor, then complete a challenge-and-verify step at sign-in. A successful second factor raises the session’s Authenticator Assurance Level from aal1 to aal2, which is recorded in the JWT and can be enforced in RLS policies, APIs, and server-side rendering.

What is an Authenticator Assurance Level (aal)?

AAL is a NIST 800-63B measure of how strongly a session’s identity was verified. A session confirmed only by a conventional method (password, magic link, OAuth, phone) is aal1. A session that also passed a verified second factor is aal2. Supabase encodes this in the JWT’s aal claim, so you can write a restrictive RLS policy such as (select auth.jwt()->>’aal’) = ‘aal2’ to require MFA on specific tables. A related amr claim lists which methods were used and when.

Why is my Supabase RLS policy slow?

Two causes account for most RLS slowdowns. First, a missing index on a column the policy filters on: adding a btree index on something like user_id can improve performance more than 100x on large tables. Second, calling auth.uid() or auth.jwt() unwrapped, which re-evaluates the function for every row. Wrapping it as (select auth.uid()) lets Postgres compute it once per statement and cache the result. Measure with EXPLAIN ANALYZE and optimize when a typical query exceeds your latency budget.

Digital Matters

IT Infrastructure Desk