Auth Overview
Every AnvilBase project has its own authentication service, powered by
Better Auth. It issues JWTs signed with the project’s unique JWT secret;
those tokens carry the authenticated scope and the user’s id, which the control
plane injects into Postgres so RLS can scope
data per user.
What it supports
- Email / password sign-up and sign-in
- Anonymous sign-in —
supabase-jssignInAnonymously()(a session with no email/password) - Magic links (passwordless email) — see Magic link
- Email OTP (passwordless 6-digit code) — see Email OTP
- Phone / SMS OTP (passwordless code over SMS, pluggable provider) — see Phone / SMS auth
- OAuth: GitHub, Google, Apple, Discord, Microsoft — see OAuth Providers
- MFA (TOTP): authenticator-app second factor — see Multi-factor auth
- Sessions with refresh tokens, configurable lifetimes (see Auth settings)
- Bans (temporary or permanent) and session revocation
- Email verification and password reset
- Audit events for every auth action — see Auth audit events
- Team invites with project roles (see RBAC)
Two ways to interact
| You are… | Use | Surface |
|---|---|---|
| an end user (your app’s customer) | the data-plane auth API | /v1/auth/<project_id>/* |
| an operator/admin (console, CLI, backend) | the management API | /api/v1/projects/<id>/users, /auth, /invites |
End users sign in through the data plane and get a JWT. Operators administer users, configure providers, and manage invites through the management plane.
The end-user flow (data plane)
The data-plane auth API is a GoTrue-compatible facade mounted under
/auth/v1, so every end-user endpoint lives at
/v1/auth/<project_id>/auth/v1/<endpoint> — the same paths supabase-js calls:
POST /v1/auth/<project_id>/auth/v1/signup {email, password} → user + sessionPOST /v1/auth/<project_id>/auth/v1/signup {} (no email/password) → anonymous user + sessionPOST /v1/auth/<project_id>/auth/v1/token?grant_type=password {email, password} → user + sessionPOST /v1/auth/<project_id>/auth/v1/otp {email} → emailed 6-digit codePOST /v1/auth/<project_id>/auth/v1/otp {phone} → SMS 6-digit codePOST /v1/auth/<project_id>/auth/v1/magiclink {email} → emailed linkPOST /v1/auth/<project_id>/auth/v1/verify {email, token, type} → user + sessionPOST /v1/auth/<project_id>/auth/v1/verify {phone, token, type:'sms'} → user + sessionGET /v1/auth/<project_id>/auth/v1/user → current user (Bearer)POST /v1/auth/<project_id>/auth/v1/logout → ends sessionGET /v1/auth/<project_id>/auth/v1/authorize?provider=<p> → OAuth redirectAfter sign-in you hold an access_token (the JWT). Attach it to data-plane
requests so they run as authenticated:
const db = createClient(ANVILBASE_URL, ANON_KEY, { global: { headers: { Authorization: `Bearer ${accessToken}` } },})See Users & Sessions for full request/response examples in each language.
Configuring auth (management plane)
Per-project auth settings live under project.settings.auth:
# Read settings (provider secrets masked)curl http://localhost:39001/api/v1/projects/<id>/auth \ -H "Authorization: Bearer $ANVILBASE_TOKEN"
# Update general settingscurl -X PATCH http://localhost:39001/api/v1/projects/<id>/auth \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{ "email_enabled": true, "magic_link_enabled": true, "min_password_length": 10, "session_duration_seconds": 3600, "refresh_token_duration_seconds": 2592000 }'| Setting | Meaning |
|---|---|
email_enabled | allow email/password |
magic_link_enabled | allow passwordless magic links |
phone_enabled | allow passwordless phone / SMS OTP (requires an SMS provider — see Phone / SMS auth) |
min_password_length | minimum password length (≥ 6) |
session_duration_seconds | access-token lifetime |
refresh_token_duration_seconds | refresh-token lifetime |
OAuth providers are configured separately — see OAuth Providers. The SMS provider for phone auth is configured separately — see Phone / SMS auth. Email delivery (magic links, verification, reset) uses per-project SMTP — see Email & Templates.
JWTs and RLS
A signed-in request carries the JWT; the control plane validates it against the project’s JWT secret and runs:
SET LOCAL ROLE authenticated;SET LOCAL app.current_user_id = '<user-uuid>';SET LOCAL request.jwt.claims = '{...}';Your policies use current_setting('app.current_user_id') to scope rows. This is
the whole point of auth in AnvilBase — turn a token into per-user data access. See
Row Level Security.
Token claims
Every user access token (and refresh token) is an HS256 JWT carrying the GoTrue-compatible claim set, so Supabase-style RLS policies evaluate unchanged:
{ "sub": "8f7c…-user-uuid", "project_id": "550e…-project-uuid", "role": "authenticated", "iat": 1781230000, "exp": 1781233600, "email": "ada@example.com", "aud": "authenticated", "app_metadata": { "provider": "email", "providers": ["email"] }, "user_metadata": { "name": "Ada Lovelace" }, "session_id": "d00e…-session-uuid"}| Claim | Meaning |
|---|---|
sub | the user’s id (uuid) — what auth.uid() returns |
project_id | the project the token is scoped to |
role | authenticated for user tokens (anon / service_role traffic uses API keys) |
iat / exp | issued-at / expiry (Unix seconds) |
email | the user’s email at mint time — what auth.email() returns |
aud | always authenticated (GoTrue audience convention) |
app_metadata | auth method info, GoTrue shape: { provider, providers } — email/OTP flows both report email |
user_metadata | the user’s metadata (signup options.data merged with updateUser data, plus name); persisted to auth.users.raw_user_meta_data; {} when none |
session_id | the auth session id; omitted on flows without a session (e.g. PKCE recovery exchange) |
The FULL claim set is passed through to Postgres as request.jwt.claims, so the
provisioned helpers resolve exactly like Supabase’s:
auth.uid() -- claims ->> 'sub' (uuid)auth.role() -- claims ->> 'role'auth.email() -- claims ->> 'email'auth.jwt() -- the full claims object (jsonb), e.g. -- auth.jwt() -> 'app_metadata' ->> 'provider'Tokens minted before claim enrichment shipped carry only
{sub, project_id, role, iat, exp}; they keep verifying until expiry, and
refreshing one mints a new fully-enriched token.
User metadata
Metadata you attach at signup or update is persisted to the database, not just echoed back:
signUp({ email, password, options: { data: { role: 'admin' } } })writes thedataobject toauth.users.raw_user_meta_data(JSONB).updateUser({ data: { nickname: 'x' } })merges into the existing metadata (GoTrue shallow-merge semantics —{ ...existing, ...data }), so the earlierroleis preserved.
Because it lands in auth.users.raw_user_meta_data, the Supabase-standard
handle_new_user() provisioning trigger works exactly as on Supabase:
create function public.handle_new_user() returns trigger as $$begin insert into public.profiles (id, role) values (new.id, new.raw_user_meta_data->>'role'); return new;end; $$ language plpgsql security definer;create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user();The persisted value is the single source of truth for the response body, the
JWT user_metadata claim, and the DB row — they always agree.
Note: brand-new users created via OTP / magic-link sign-in do not yet capture signup-time
options.dataat first verify (the user is created inside the plugin at verify time, where the send-timedatais no longer available). For those, callupdateUser({ data })after the first sign-in, or use password / anonymous signup — both persist metadata immediately. Existing users signing in via OTP/magic-link always return their already-persisted metadata.
Rotating the JWT secret
If a project’s JWT secret is compromised, rotate it — all existing tokens become invalid immediately:
anvilbase secrets rotate jwt --project <id>See API Keys & Scopes → Rotation and the JWT-leak runbook.
SDK auth compatibility
Because the data-plane auth API speaks the GoTrue wire contract, supabase-js’s
auth module works directly on the same createClient instance —
signUp, signInWithPassword, signInWithOtp, signInAnonymously,
verifyOtp, and OAuth via signInWithOAuth. No manual JWT fetch or header
injection is needed for SDK flows. The service-role supabase.auth.admin.*
user-management surface is served too (it requires the project service_role
key). See SDK Compatibility for the full matrix.
Next: Users & Sessions.