Users & Sessions
This page covers both halves: the end-user sign-up/sign-in flow (data plane) and administrative user management (management plane / CLI / console).
End-user authentication (data plane)
The data-plane auth API is a GoTrue-compatible facade mounted under
/auth/v1, so every endpoint below lives at
/v1/auth/<project_id>/auth/v1/<endpoint> — the exact paths supabase-js
calls.
Sign up
curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/signup" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"s3cret-passphrase"}'const res = await fetch(`${HOST}/v1/auth/${PROJECT_ID}/auth/v1/signup`, { method: 'POST', headers: { 'apikey': ANON_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }),})const { user, access_token, refresh_token } = await res.json()import httpxr = httpx.post(f"{HOST}/v1/auth/{PROJECT_ID}/auth/v1/signup", headers={"apikey": ANON_KEY}, json={"email": email, "password": password})session = r.json()A successful sign-up returns a full session:
{ "user": { "id": "…", "email": "alice@example.com", "...": "…" }, "access_token": "<jwt>", "token_type": "bearer", "expires_in": 3600, "expires_at": 1781233600, "refresh_token": "<jwt>"}If
email_confirmation_requiredis enabled, sign-up does not return a session — the response is{ "user": {...}, "session": null }and AnvilBase emails a 6-digit code. The client completes sign-up by posting that code to/auth/v1/verify(see Verify below and Auth settings).
Sign in
Sign-in is the GoTrue token endpoint with the password grant — there is no
/sign-in route:
curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/token?grant_type=password" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"s3cret-passphrase"}'The response is the same full session shape as sign-up: { user, access_token, token_type, expires_in, expires_at, refresh_token }. Store the
access_token (the project JWT) in a secure cookie or secure storage and
attach it to data-plane calls:
const db = createClient(ANVILBASE_URL, ANON_KEY, { global: { headers: { Authorization: `Bearer ${access_token}` } },})// now db.from('todos').select() runs as this user, RLS-scopedRefresh a session with the same endpoint and the refresh_token grant:
curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/token?grant_type=refresh_token" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"refresh_token":"<refresh_token>"}'Refresh tokens & revocation
- A refresh token is usable only at
?grant_type=refresh_token. It is not a Bearer access credential: sending it asAuthorization: Bearer <refresh>(orapikey: <refresh>) to a REST / Storage / Functions / Realtime route returns 401. Refresh tokens carry a distincttoken_use: "refresh"claim that the control plane rejects as an access credential. logout(supabase-jssignOut()) revokes all of the user’s refresh tokens immediately. Sign-out records a per-user revocation watermark, so every refresh token issued before it stops working — the refresh grant returns401 refresh_token_not_founduntil the user signs in again. Access tokens are short-lived (1 h) and simply expire on their own; a fresh sign-in mints tokens that work normally.- Bounded transition on upgrade: tokens minted before upgrading to this
behavior carry no
token_useclaim, so they keep their old behavior until they expire (at most 7 days). No existing session is logged out on deploy. - Token lifetimes follow the project settings.
expires_in(and the JWTexp) reflect the project’ssession_duration_seconds; the refresh token’s lifetime reflectsrefresh_token_duration_seconds. The3600above is the default — an operator who sets a shorter session (e.g.900for compliance) sees that duration in the tokenssupabase-jsuses. user_metadataandaalare preserved across refresh. A refreshed session keeps the sameuser_metadata(e.g. aroleclaim used by RLS) and the same MFA assurance level (aal) as the original — so JWT-metadata RLS andaal2step-up gates stay enforced assupabase-jsauto-refreshes in the background (they no longer silently downgrade after ~1 h).
Magic link (passwordless)
curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/magiclink" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com"}'Requires magic_link_enabled and configured SMTP
(Email & Templates). See Magic link.
Email OTP (passwordless 6-digit code)
# 1. Request a codecurl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/otp" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com"}'Then exchange the emailed code via Verify below. See Email OTP for the full flow.
Verify (email OTP / confirmation) {#verify-email-otp—confirmation}
/verify completes any code-based flow — passwordless email-OTP sign-in and the
sign-up email-confirmation gate. Post the email plus the 6-digit code with
type: "email":
curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/verify" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","token":"123456","type":"email"}'A valid code returns the same full session shape ({ user, access_token, token_type, expires_in, expires_at, refresh_token }). This is exactly what the
SDK’s verifyOtp({ email, token, type: 'email' }) calls.
/verify also completes password recovery (type: "recovery", see
Password recovery below) and email change
(type: "email_change", see Change email below) with the same
6-digit code — both return a session on success.
Password recovery {#password-recovery}
POST /recover { email } always returns 200 {} (it never reveals whether the
address exists). How the reset is delivered depends on the flow:
- PKCE (the SDK sends a
code_challengeand an allow-listedredirect_to): AnvilBase emails a?code=deep link, exchanged viaPOST /token?grant_type=pkce. - Implicit / OTP (no
code_challenge): AnvilBase emails a 6-digit recovery code. Complete it in two steps — verify the code for a short-lived session, then set the new password:
# 1. Request the reset (implicit flow — no code_challenge)curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/recover" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com"}'
# 2. Verify the emailed code → short-lived recovery session (expires_in: 900)curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/verify" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","token":"123456","type":"recovery"}'
# 3. Set the new password with the recovery access tokencurl -X PUT "http://localhost:39001/v1/auth/<project_id>/auth/v1/user" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $RECOVERY_ACCESS_TOKEN" \ -H "Content-Type: application/json" -d '{"password":"new-password"}'This is exactly what the SDK’s resetPasswordForEmail() +
verifyOtp({ type: 'recovery' }) + updateUser({ password }) sequence calls.
Implicit-flow recovery is fully supported — /verify no longer returns 501.
Current user & sign out
There is no /session endpoint — read the current user with the GoTrue user
endpoint (Bearer token required), and end the session with logout:
curl "http://localhost:39001/v1/auth/<project_id>/auth/v1/user" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/logout" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ACCESS_TOKEN"logout does more than end the current session: it records a per-user
revocation watermark that immediately invalidates every refresh token the
user holds (see Refresh tokens & revocation
above). A stolen refresh token is dead on its next use once the user signs out.
Change email {#change-email}
An end user changes their own email with PUT /user { email } (the SDK’s
updateUser({ email })), Bearer their access token. The behavior depends on the
project’s email_confirmation_required setting:
-
Confirmation on (recommended). The primary email is not changed immediately. AnvilBase emails a 6-digit code to the new address and returns the pending address as
new_emailon the user object; the current email and itsemail_confirmed_atstay put. The change applies only after the user confirms the code:Terminal window # New email is emailed a 6-digit code; primary email unchanged until confirmed.curl -X PUT ".../auth/v1/user" -H "Authorization: Bearer $ACCESS_TOKEN" \-H "apikey: $ANON_KEY" -d '{"email":"new@example.com"}'# Confirm the change with the code sent to the NEW address.curl -X POST ".../auth/v1/verify" -H "apikey: $ANON_KEY" \-d '{"email":"new@example.com","token":"123456","type":"email_change"}' -
Confirmation off (autoconfirm). The change applies immediately, matching Supabase’s autoconfirm behavior.
In both modes, an email already registered to another user returns
422 email_exists and leaves the current address unchanged.
OAuth
Redirect the browser to the authorize entry point; AnvilBase handles the callback and issues a session:
GET /v1/auth/<project_id>/auth/v1/authorize?provider=githubGET /v1/auth/<project_id>/auth/v1/authorize?provider=googleConfigure providers first — see OAuth Providers.
Administering users (management plane)
These run as an operator (admin token / PAT) and are wrapped by the CLI and console.
From the SDK (supabase.auth.admin.*)
A client created with the project service_role key can administer its own
users through the drop-in supabase.auth.admin.* namespace — no platform-wide
admin token required:
// A client created with the project service_role key can administer its own users:const admin = createClient(`${HOST}/v1/${PROJECT_ID}`, SERVICE_ROLE_KEY).auth.admin;await admin.listUsers();await admin.getUserById(id);await admin.createUser({ email, password, email_confirm: true });await admin.updateUserById(id, { email_confirm: true });await admin.deleteUser(id);Notes:
- These require the service_role key. The control plane validates the key and
stamps its verified scope for the auth facade; an anon (or authenticated)
key calling any
admin.*method gets 403not_admin— no privilege escalation, and the header cannot be forged from the data plane. - They are naturally scoped to the calling project: a service_role key for project A cannot administer project B (the control plane rejects the cross-tenant call before it reaches the facade). No platform-wide admin token is ever needed for per-project user administration.
generateLink/inviteUserByEmailare not yet supported through this namespace (they need email-link minting the facade does not implement) — use the OTP / magic-link flows or the management API below.- Arbitrary
user_metadatakeys sent tocreateUser/updateUserByIdare echoed in the response for SDK shape but persistence of new keys depends on the authmetadatacolumn (a separate task);email_confirm,role,email, andpasswordare applied.
List & search
anvilbase auth users list --project <id>
curl "http://localhost:39001/api/v1/projects/<id>/users?search=alice&limit=50" \ -H "Authorization: Bearer $ANVILBASE_TOKEN"Returns { "users": [...], "total", "limit", "offset" }; search matches email
(contains).
Create
anvilbase auth users create alice@example.com --project <id> --name "Alice"# (prompts for a password if --password is omitted)curl -X POST http://localhost:39001/api/v1/projects/<id>/users \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"s3cret","email_verified":true,"name":"Alice"}'Get & delete
curl http://localhost:39001/api/v1/projects/<id>/users/<user_id> -H "Authorization: Bearer $ANVILBASE_TOKEN"anvilbase auth users delete <user_id> --project <id>Ban & unban
# Temporary ban (seconds); omit --expires-in for permanentanvilbase auth users ban <user_id> --project <id> --reason "spam" --expires-in 3600anvilbase auth users unban <user_id> --project <id>curl -X POST http://localhost:39001/api/v1/projects/<id>/users/<user_id>/ban \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{"ban_reason":"abuse","ban_expires_in":86400}'Sessions
# List a user's active sessionscurl http://localhost:39001/api/v1/projects/<id>/users/<user_id>/sessions \ -H "Authorization: Bearer $ANVILBASE_TOKEN"
# Revoke ALL of a user's sessions (force re-login everywhere)anvilbase auth sessions revoke --project <id> --user <user_id>
# Revoke ONE session by its opaque token (from the list response)curl -X DELETE http://localhost:39001/api/v1/projects/<id>/users/<user_id>/sessions/<session_token> \ -H "Authorization: Bearer $ANVILBASE_TOKEN"Revoking sessions is the immediate lever after a suspected account compromise; pair it with a JWT secret rotation if the project’s signing key may be exposed.
In the console
Project → Users lists accounts with search, lets you create/delete/ban, and shows sessions. Team membership (invites and roles) is on the Team tab — see RBAC & Team Management.
Next: OAuth Providers.