Skip to content

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

Terminal window
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 httpx
r = 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_required is 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:

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

Refresh a session with the same endpoint and the refresh_token grant:

Terminal window
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 as Authorization: Bearer <refresh> (or apikey: <refresh>) to a REST / Storage / Functions / Realtime route returns 401. Refresh tokens carry a distinct token_use: "refresh" claim that the control plane rejects as an access credential.
  • logout (supabase-js signOut()) 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 returns 401 refresh_token_not_found until 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_use claim, 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 JWT exp) reflect the project’s session_duration_seconds; the refresh token’s lifetime reflects refresh_token_duration_seconds. The 3600 above is the default — an operator who sets a shorter session (e.g. 900 for compliance) sees that duration in the tokens supabase-js uses.
  • user_metadata and aal are preserved across refresh. A refreshed session keeps the same user_metadata (e.g. a role claim used by RLS) and the same MFA assurance level (aal) as the original — so JWT-metadata RLS and aal2 step-up gates stay enforced as supabase-js auto-refreshes in the background (they no longer silently downgrade after ~1 h).
Terminal window
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)

Terminal window
# 1. Request a code
curl -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":

Terminal window
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_challenge and an allow-listed redirect_to): AnvilBase emails a ?code= deep link, exchanged via POST /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:
Terminal window
# 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 token
curl -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:

Terminal window
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_email on the user object; the current email and its email_confirmed_at stay 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=github
GET /v1/auth/<project_id>/auth/v1/authorize?provider=google

Configure 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 403 not_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 / inviteUserByEmail are 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_metadata keys sent to createUser / updateUserById are echoed in the response for SDK shape but persistence of new keys depends on the auth metadata column (a separate task); email_confirm, role, email, and password are applied.
Terminal window
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

Terminal window
anvilbase auth users create alice@example.com --project <id> --name "Alice"
# (prompts for a password if --password is omitted)
Terminal window
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

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

Terminal window
# Temporary ban (seconds); omit --expires-in for permanent
anvilbase auth users ban <user_id> --project <id> --reason "spam" --expires-in 3600
anvilbase auth users unban <user_id> --project <id>
Terminal window
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

Terminal window
# List a user's active sessions
curl 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.