Supabase Auth gets you signed in within ten lines of code. And those same ten lines, shipped to production, usually leave three holes open.
- JWTs are still signed with a shared secret (HS256) — hard to revoke, and every verification costs a round trip to the Auth server
- Permissions live in
user_metadata— the one place users can rewrite themselves - MFA is enforced by an application
ifstatement — one missed path and it's all bypassed
This article is an implementation guide for making Supabase Auth production-grade. Faithful to the official documentation as of 2026-08-16, it covers choosing an auth method, migrating to asymmetric keys with zero downtime, designing session lifetimes, enforcing MFA in RLS, using anonymous sign-ins safely, and defending against abuse — with code you can use as-is.
What this article does not cover: wiring into the Next.js App Router (
@supabase/ssr,createBrowserClient/createServerClient, cookie getAll/setAll, refreshing tokens in middleware) gets a full article of its own in Making Supabase RLS actually work in the Next.js App Router. This article focuses on the Auth side of the design.
1. Choosing an auth method: it follows from who uses the product
Per the official documentation, Supabase Auth provides:
- Password-based (email + password)
- Passwordless (magic link / one-time password)
- Social login (19 providers)
- Phone auth (MessageBird / Twilio / Vonage)
- Enterprise SSO (SAML 2.0)
- Multi-factor authentication (TOTP or phone)
- Custom providers (any OAuth2 or OIDC-compatible identity provider)
Plus anonymous sign-ins. Choose not by comparing feature matrices but by asking who uses this.
| Target users | Default method | Rationale and caveats |
|---|---|---|
| Consumers (B2C) | Password + social | Lowest friction. Always enable password strength settings and leaked-password protection (Pro and above) |
| One-off or infrequent use | Magic link / OTP | Removes password management entirely. The built-in email provider is limited to 2 emails per hour, so custom SMTP is effectively mandatory |
| Internal systems, B2B | SSO (SAML 2.0) | Moves account lifecycle to the IdP. The only realistic answer when immediate revocation on offboarding is a requirement |
| "Try it before you sign up" products | Anonymous sign-in → upgrade | Lets you show the experience first. Requires abuse controls (chapter 7) |
| Anything handling money or personal data | The above + MFA | Enforce AAL in RLS (chapter 6) |
The email endpoint limits shape your design. Per the official rate-limit table, with the built-in email provider /auth/v1/signup, /auth/v1/recover, and /auth/v1/user are capped at 2 emails per hour project-wide. You will not notice this in development, and you will certainly hit it right after launch. Put configuring custom SMTP (Resend, Amazon SES, etc.) on the pre-launch checklist.
2. Where user data lives: don't touch the auth schema
Right after sign-in comes "where do I keep the user's profile?" The answer is your own table in the public schema. The official reasoning:
Primary keys are guaranteed not to change. Columns, indices, constraints or other database objects managed by Supabase may change at any time.
In other words, you may reference only the primary key of auth.users as a foreign key — never other columns or indexes.
create table public.profiles (
id uuid not null references auth.users on delete cascade,
first_name text,
last_name text,
primary key (id)
);
alter table public.profiles enable row level security;
Create the row on signup with a trigger:
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, first_name, last_name)
values (
new.id,
new.raw_user_meta_data ->> 'first_name',
new.raw_user_meta_data ->> 'last_name'
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Write security definer set search_path = '' as a unit. Without emptying the search path, a function from an unintended schema can be resolved through it (see SECURITY DEFINER functions and search_path).
Critical: user_metadata cannot be used for authorization
The raw_user_meta_data supplied at signup (i.e. user_metadata) can be rewritten by the user via updateUser(). Put role: "admin" there and authorize against it, and you have built an app where anyone can become an administrator.
Permissions belong in one of:
app_metadata— writable only from the server side- A dedicated table (
public.user_roles, etc.) — managed together with RLS
Getting them into the JWT is the job of the custom access token hook in chapter 5.
3. Getting email auth right: token_hash and /auth/confirm
Magic links and email confirmation have a signature failure mode: get one place wrong and clicking the link doesn't sign you in. The correct shape is this.
First, change the email template to use {{ .TokenHash }}.
<h2>Sign in to your account</h2>
<p>Use this link to sign in to your account:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>
The send side uses signInWithOtp:
const { data, error } = await supabase.auth.signInWithOtp({
email: "valid.email@supabase.io",
options: {
// false if you don't want unknown addresses to silently create users
shouldCreateUser: false,
emailRedirectTo: "https://example.com/welcome",
},
});
The receiving /auth/confirm exchanges the hash for a session via verifyOtp:
// app/auth/confirm/route.ts (Next.js App Router)
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
// Open-redirect defense: never accept an external URL
const nextParam = searchParams.get("next") ?? "/";
const next = nextParam.startsWith("/") && !nextParam.startsWith("//") ? nextParam : "/";
if (!tokenHash || !type) {
return NextResponse.redirect(new URL("/auth/error?reason=missing_token", origin));
}
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type });
if (error) {
// Don't put error detail in the URL (it becomes a user-enumeration / leak path)
return NextResponse.redirect(new URL("/auth/error?reason=invalid_token", origin));
}
return NextResponse.redirect(new URL(next, origin));
}
Three practical points:
- Validate the
nextparameter. Redirecting straight tonextis an open redirect. Always confirm it starts with/and not//. - Don't return detailed error reasons. "No account exists for this email" is the front door to user enumeration. The official docs note that
resetPasswordForEmail"doesn't reveal whether an account exists for the given email address." Hold the same posture in your own handlers. - Get the redirect allowlist right.
The redirect allowlist and wildcards
URLs passed to redirectTo must be registered in the dashboard's Redirect URLs. Understand the wildcards precisely:
| Pattern | Matches |
|---|---|
http://localhost:3000/* | /foo, /bar (not /foo/bar) |
http://localhost:3000/** | /foo, /bar, /foo/bar |
http://localhost:3000/? | Single-character paths only |
Separators are . and /. * does not cross a separator; ** does — that distinction is where accidents come from. For Vercel preview deployments the official recommendation is to set Site URL to your production URL and additionally register http://localhost:3000/** and https://*-<team-or-account-slug>.vercel.app/**.
4. JWT signing keys: from HS256 to ES256, with zero downtime
This is the dividing line between an introduction and production quality.
Supabase's legacy approach used a single shared secret (HS256) across the whole project. The official docs state this is "not recommended for production applications" due to revocation complexity and security risks.
The currently supported signing keys:
| Algorithm | JWT alg | Position |
|---|---|---|
| NIST P-256 Curve | ES256 | Recommended for production. "Elliptic Curves are a faster alternative than RSA" with shorter signatures |
| RSA 2048 | RS256 | Widely supported but "significantly slower than elliptic curves" |
| Ed25519 Curve | EdDSA | Coming soon; runtime support is currently limited |
| HMAC shared secret | HS256 | Not recommended for production |
Why asymmetric keys make things faster
With asymmetric keys, the public key is published via a JWKS endpoint:
GET https://project-id.supabase.co/auth/v1/.well-known/jwks.json
That endpoint is cached by Supabase's edge for 10 minutes. As a result supabase.auth.getClaims() can verify locally, with no round trip to the Auth server. The official explanation:
This endpoint is often cached, resulting in significantly faster responses (compared to
getUser). If your project uses symmetric JWT signing, the method sends a request to the Auth server — similar togetUser— since local verification isn't possible.
So migrating to asymmetric keys is both a security improvement and a latency improvement. In Next.js, where server components make authorization decisions on every render, the effect is substantial.
Choosing between the three methods
| Method | What it does | Where to use it |
|---|---|---|
getClaims() | Verifies locally via JWKS with asymmetric keys; asks the server with symmetric keys | First choice for server-side authorization |
getUser() | Asks the Auth server and verifies | When you need the freshest user information |
getSession() | Returns a value from client storage without revalidating | Must not be used for server-side authorization |
Migration steps (without forcing anyone to sign out)
Signing keys have four states:
Standby … Created but not yet used to issue tokens. Public key appears in JWKS
↓ rotate
In Use … The key currently issuing tokens
↓ rotate
Previously … The prior key. Still trusted for verifying existing tokens
Used
↓ revoke
Revoked … No longer accepted for verification
The official dashboard procedure:
- On the JWT signing keys page, click "Migrate JWT secret" — this imports your existing shared secret into the new system and simultaneously creates an asymmetric standby key
- Review whether your app depends on the signing key directly (verifying by hand with
joseorjsonwebtoken) - Click "Rotate keys" to make the asymmetric key current
- Wait for the access token expiry (e.g. 1 hour) plus a buffer (15 minutes) before revoking the legacy secret
Two things the official docs warn to check first:
Make sure your app does not directly rely on the legacy JWT secret. If it's verifying every JWT against the legacy JWT secret… continuing with the rotation might break those components.
If you're using Edge Functions that have the Verify JWT setting, continuing with the rotation might break your app. You will need to turn off this setting.
As for user impact:
Non-expired access tokens will remain to be accepted, so no users will be forcefully signed out.
If you verify tokens yourself anywhere, replace that with getClaims() before migrating.
// Before: hand-verifying with the shared secret (an obstacle to migration)
// const payload = jwt.verify(token, process.env.SUPABASE_JWT_SECRET!);
// After: independent of the signing key type
const { data, error } = await supabase.auth.getClaims();
if (error || !data) {
// treat as unauthenticated
}
const userId = data.claims.sub;
const role = data.claims.role;
Note that getClaims() "is meant to be used only with JWTs issued by Supabase Auth" — it is not for verifying third-party IdP tokens.
5. Session design: you decide the lifetime
The official definition:
A session is created when a user signs in. By default, it lasts indefinitely and a user can have an unlimited number of active sessions on as many devices.
"By default, it lasts indefinitely" — skim past that and a departed employee's laptop stays signed in forever.
| Item | Default | Notes |
|---|---|---|
| Access token expiry | 1 hour | "Most applications should use the default expiration time of 1 hour." Under 5 minutes is discouraged (server load, clock skew, errors during long-running requests) |
| Refresh token | Never expires, single use | Every use issues a new pair (rotation) |
| Refresh token reuse interval | 10 seconds | "By default this is 10 seconds and we do not recommend changing this value" |
| Session time-box | Not set | Pro plan and above |
| Inactivity timeout | Not set | Pro plan and above |
| Single-session enforcement | Not set | Pro plan and above |
Why the 10-second reuse interval exists
If a refresh token were strictly single-use, having two tabs open would break you. Both would attempt a refresh at nearly the same moment, and one would grab an already-used token and get signed out. The 10-second grace absorbs that race — which is exactly why the docs advise against changing it.
What to configure
- Leave the access token at 1 hour. Shortening it multiplies refreshes and pushes you toward the IP-based limit on
/auth/v1/token(1,800 requests per hour). - If you handle money or personal data, set a time-box and inactivity timeout (Pro and above). "Lasts indefinitely" is guaranteed to come up in an audit.
- Be deliberate about sign-out scope. Whether to drop every device or just this session depends on the requirement.
6. MFA and AAL: enforce it in the database
Multi-factor authentication must not be enforced by an application if statement, because one missed path bypasses everything.
Supabase expresses authentication strength as AAL (Authenticator Assurance Level) in the JWT's aal claim.
- aal1 — "identity was verified using a conventional login method such as email+password, magic link, one-time password, phone auth or social login"
- aal2 — "identity was additionally verified using at least one second factor, such as a TOTP code or One-Time Password code"
When the claim is absent it is treated as aal1.
From enrollment to verification
There are three API groups: Enrollment (adding and removing factors), Challenge and Verify (confirming access to a factor), and List Factors (displaying enrolled factors). Also available are unenroll(), listFactors(), and getAuthenticatorAssuranceLevel(). Supported factors are TOTP (authenticator apps) and phone.
Enforce it in RLS
This is the heart of it. The shape the official docs show:
-- Require MFA for all users
create policy "mfa_required"
on public.sensitive_table
as restrictive
to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
-- Require aal2 only for users who have enrolled MFA (phased rollout)
create policy "mfa_required_for_enrolled"
on public.sensitive_table
as restrictive
to authenticated
using (
array[(select auth.jwt()->>'aal')] <@ (
select case
when count(id) > 0 then array['aal2']
else array['aal1', 'aal2']
end
from auth.mfa_factors
where user_id = (select auth.uid()) and status = 'verified'
)
);
as restrictive is the essence. Ordinary (permissive) policies combine with OR, so a single other policy can satisfy the condition. Restrictive policies combine with AND, so the MFA condition applies no matter what any other policy permits. The official docs likewise state that all such policies "should use the as restrictive clause to override permissive policies."
Wrapping in (select auth.jwt()->>'aal') is a performance optimization that avoids per-row re-evaluation (see RLS performance optimization).
The order of a phased rollout
- Ship the MFA enrollment UI (optional enrollment)
- Add the "require it only for enrolled users" policy above — unenrolled users are unaffected
- Once adoption is high enough, make it mandatory for high-privilege roles such as administrators
- Finally, switch everything to
= 'aal2'
Starting at step 4 locks out every user.
7. Anonymous sign-ins: show the experience first, but defend it
For products where you want people to try before registering, anonymous sign-in works.
const { data, error } = await supabase.auth.signInAnonymously();
An anonymous user's JWT carries an is_anonymous claim. In the official wording: "JWTs for these users will have an is_anonymous claim which you can use to distinguish in RLS policies."
Declare in RLS what anonymous users cannot do
create policy "Only permanent users can post to the news feed"
on public.news_feed
as restrictive
for insert
to authenticated
with check ((select (auth.jwt()->>'is_anonymous')::boolean) is false);
as restrictive again. "Can read but not write," "can try but not publish" — declare that boundary in the database, not in the application.
Upgrading to a permanent account
- Email / phone: pass an address to
updateUser(). A password can be added after verification - OAuth: pass a provider to
linkIdentity()
Identity linking carries an important constraint. Supabase automatically links identities that share the same email address to one user. But because "it would also be an insecure practice to automatically link an identity to a user with an unverified email address," email verification is a prerequisite. It is the linchpin of account-takeover prevention. Also, unlinking a single identity requires at least two linked identities.
Abuse controls are mandatory
The official warning is explicit:
Since anonymous users are stored in your database, bad actors can abuse the endpoint to increase your database size drastically.
Countermeasures:
- Enable CAPTCHA (officially recommended)
- The default IP-based limit is 30 requests per hour, changeable in the dashboard
- Build the periodic deletion of stale anonymous users into your operations from day one (retrofitting it makes deciding which rows are safe to delete much harder)
8. Custom claims: putting permissions in the JWT
As covered in chapter 2, permissions cannot live in user_metadata. The sanctioned way to get them into the JWT is a custom access token hook.
The hook is a Postgres function, called by Auth just before it issues a token. Input and output are JSON:
Input: { "user_id": "...", "claims": { ... }, "authentication_method": "..." }
Output: { "claims": { ... } }
The official example (an access restriction hook) includes the grant / revoke convention:
create or replace function public.restrict_application_access(event jsonb)
returns jsonb
language plpgsql
as $function$
declare
authentication_method text;
email_claim text;
allowed_emails text[] := array['myemail@company.com', 'example@company.com'];
begin
email_claim = event->'claims'->>'email';
authentication_method = event->'authentication_method';
authentication_method = replace(authentication_method, '"', '');
if email_claim ilike '%@supabase.io'
or authentication_method = 'sso/saml'
or email_claim = any(allowed_emails) then
return event;
end if;
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 403,
'message', 'Staging access is only allowed to team members. Please use your @company.com account instead'
)
);
end;
$function$
;
grant execute on function public.restrict_application_access to supabase_auth_admin;
revoke execute on function public.restrict_application_access from authenticated, anon, public;
The last two lines matter most. Grant execution only to supabase_auth_admin and strip it from authenticated / anon / public. Forget that, and ordinary users can call the hook function itself.
Attaching a role follows the same skeleton:
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role text;
begin
select role into user_role
from public.user_roles
where user_id = (event->>'user_id')::uuid;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{app_metadata,app_role}', to_jsonb(user_role));
end if;
return jsonb_set(event, '{claims}', claims);
end;
$$;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
Watch out for JWT bloat
Don't dismiss the warning the docs attach:
The size of the JWT can be a problem especially if you're using a Server-Side Rendering framework.
The JWT rides in a cookie and travels on every request. One role name is fine; stuff in a permissions array or large OAuth provider claims and you hit cookie size limits, with header size showing up as latency. The principle is: put only the minimum needed for authorization decisions in the JWT, and read the details from the database.
(How to express role-based authorization on the RLS side is covered in RLS × RBAC and custom claims.)
9. Abuse controls: rate limits and passwords
Supabase Auth ships with default rate limits. The numbers worth knowing at design time:
| Operation | Path | Limited by | Default | Customizable |
|---|---|---|---|---|
| Email send | /auth/v1/signup, /auth/v1/recover, /auth/v1/user | Project-wide | 2 emails per hour (built-in provider) | Custom SMTP only |
| Send OTPs | /auth/v1/otp | Project-wide | 30 OTPs per hour | Yes |
| OTPs / magic links | /auth/v1/otp | Per user | 60-second window | Yes |
| Signup confirmation | /auth/v1/signup | Per user | 60-second window | Yes |
| Password reset | /auth/v1/recover | Per user | 60-second window | Yes |
| Verification requests | /auth/v1/verify | IP address | 360 requests per hour (bursts up to 30) | No |
| Token refresh | /auth/v1/token | IP address | 1,800 requests per hour (bursts up to 30) | No |
| MFA challenge / verify | /auth/v1/factors/:id/challenge, /verify | IP address | 15 requests per hour | No |
| Anonymous sign-ins | /auth/v1/signup | IP address | 30 requests per hour (bursts up to 30) | No |
15 MFA attempts per hour is a level legitimate use can reach. When a user keeps mistyping a code, don't stop at an error message — guide them to "wait and try again."
On the password side:
- Minimum length — "Anything less than 8 characters is not recommended"
- Required character types — "Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols"
- Leaked password protection — rejects passwords known to HaveIBeenPwned's Pwned Passwords API. Pro plan and above
Existing users can keep signing in with their current passwords regardless of new requirements, but a weak password returns a WeakPasswordError on sign-in. Rather than swallowing it, the good design is to turn it into a path to updating the password.
10. Pre-production checklist
- JWT signing keys migrated to asymmetric (ES256), or a migration plan exists
- Hand-rolled JWT verification (
jose/jsonwebtoken) replaced withgetClaims() - Edge Functions' Verify JWT setting audited (mandatory before key rotation)
-
getSession()is not used for server-side authorization decisions - Permissions are not in
user_metadata(app_metadataor a dedicated table instead) - The custom access token hook has both
grant(supabase_auth_admin) andrevoke(authenticated/anon/public) - Claims put in the JWT are minimal (cookie size checked)
-
/auth/confirmvalidates thenextparameter (open-redirect defense) - Error messages cannot be used for user enumeration
- The redirect allowlist was registered with the
*vs**distinction understood - Custom SMTP configured (the built-in provider allows 2 emails per hour)
- Session time-box / inactivity timeout configured per requirements
- MFA enforced by an
as restrictiveRLS policy (not by applicationifstatements alone) - If using anonymous sign-ins: CAPTCHA,
is_anonymousrestrictions in RLS, and a deletion routine for stale anonymous users - Password strength settings and leaked-password protection (Pro and above) enabled
-
public.profilesreferences only the primary key ofauth.users
Conclusion: the strength of authentication is decided by where the decision is made
Making Supabase Auth production-grade isn't about adding features. It's about relocating the decisions.
- Make the signing key asymmetric, and verification happens locally with a JWKS public key
- Put permissions in
app_metadataand a hook, and decisions rest on server-side facts - Write MFA into RLS, and enforcement doesn't depend on the number of application paths
- Write anonymous restrictions into RLS, and the boundary is immune to missed code paths
What they share is a single idea: move the decision somewhere that does not depend on the application code being written correctly. Authentication looks like a front-door concern, but it is really a decision about where the system's trust boundary sits. Projects that place it correctly at the start don't break as features accumulate.