Skip to content

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-insupabase-js signInAnonymously() (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…UseSurface
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 + session
POST /v1/auth/<project_id>/auth/v1/signup {} (no email/password) → anonymous user + session
POST /v1/auth/<project_id>/auth/v1/token?grant_type=password {email, password} → user + session
POST /v1/auth/<project_id>/auth/v1/otp {email} → emailed 6-digit code
POST /v1/auth/<project_id>/auth/v1/otp {phone} → SMS 6-digit code
POST /v1/auth/<project_id>/auth/v1/magiclink {email} → emailed link
POST /v1/auth/<project_id>/auth/v1/verify {email, token, type} → user + session
POST /v1/auth/<project_id>/auth/v1/verify {phone, token, type:'sms'} → user + session
GET /v1/auth/<project_id>/auth/v1/user → current user (Bearer)
POST /v1/auth/<project_id>/auth/v1/logout → ends session
GET /v1/auth/<project_id>/auth/v1/authorize?provider=<p> → OAuth redirect

After 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:

Terminal window
# Read settings (provider secrets masked)
curl http://localhost:39001/api/v1/projects/<id>/auth \
-H "Authorization: Bearer $ANVILBASE_TOKEN"
# Update general settings
curl -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
}'
SettingMeaning
email_enabledallow email/password
magic_link_enabledallow passwordless magic links
phone_enabledallow passwordless phone / SMS OTP (requires an SMS provider — see Phone / SMS auth)
min_password_lengthminimum password length (≥ 6)
session_duration_secondsaccess-token lifetime
refresh_token_duration_secondsrefresh-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"
}
ClaimMeaning
subthe user’s id (uuid) — what auth.uid() returns
project_idthe project the token is scoped to
roleauthenticated for user tokens (anon / service_role traffic uses API keys)
iat / expissued-at / expiry (Unix seconds)
emailthe user’s email at mint time — what auth.email() returns
audalways authenticated (GoTrue audience convention)
app_metadataauth method info, GoTrue shape: { provider, providers } — email/OTP flows both report email
user_metadatathe user’s metadata (signup options.data merged with updateUser data, plus name); persisted to auth.users.raw_user_meta_data; {} when none
session_idthe 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 the data object to auth.users.raw_user_meta_data (JSONB).
  • updateUser({ data: { nickname: 'x' } }) merges into the existing metadata (GoTrue shallow-merge semantics — { ...existing, ...data }), so the earlier role is 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.data at first verify (the user is created inside the plugin at verify time, where the send-time data is no longer available). For those, call updateUser({ 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:

Terminal window
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.