Skip to content

Authentication API

Every endpoint under /api/v1/auth/<collection>/... and the admin auth flow.

All endpoints below operate on a collection of type: "auth". Calls against non-auth collections return 422.

For the conceptual overview see Authentication.

POST /api/v1/admin/setup
X-Setup-Key: <setup key>
{ "email": "...", "password": "..." }

One-time setup — creates the first admin. After that, returns 400. The setup key is VAULTBASE_SETUP_KEY, or the key the server prints to its log at first boot. Send it as the X-Setup-Key header or as setup_key in the body; without it the call returns 401. The password must meet the password policy.

POST /api/v1/admin/auth/login
{ "email": "...", "password": "..." }
→ { "data": { "token": "<jwt>", "admin": { "id": "...", "email": "..." } } }
GET /api/v1/admin/auth/me
→ { "data": <jwt payload> }
POST /api/v1/auth/<col>/register
{ "email": "...", "password": "...", ...the collection's own fields (unknown keys are ignored) }
→ 403 when the collection's create_rule doesn't allow the sign-up
POST /api/v1/auth/<col>/login
{ "email": "...", "password": "..." }

/login returns one of:

{ "data": { "token": "<jwt>", "record": { "id": "...", "email": "..." } } }
{ "data": { "mfa_required": true, "mfa_token": "..." } }
POST /api/v1/auth/<col>/login/mfa
{ "mfa_token": "...", "code": "<6 digits>" }
# OR — single-use recovery code instead of TOTP code
POST /api/v1/auth/<col>/login/mfa
{ "mfa_token": "...", "recovery_code": "ABCD-EF23" }
GET /api/v1/auth/me ← user JWT
POST /api/v1/auth/refresh ← user OR admin JWT, re-issues 7d token

A refreshed token keeps the original’s collection and anonymous status. Impersonation tokens can’t be refreshed (403): impersonation sessions end when their token expires.

Refreshing revokes the token you sent: keep the new one and drop the old one (the SDK does this for you). Refreshing the same token again within 30 seconds (another tab, a retried request) returns the same new token; after that it returns 401.

A sign-in can be kept alive by refreshing for at most auth.session.max_seconds (default 30 days, 0 = no cap), counted from the original sign-in, which refreshed tokens carry as the auth_time claim. The new token never outlives that cap; past it, /refresh returns 401 and the user or admin signs in again. Anonymous sessions are exempt — a guest has no credential to sign in with again.

POST /api/v1/auth/<col>/request-verify ← auth required, no body
POST /api/v1/auth/<col>/verify-email { "token": "..." }

Verification emails are sent via the verify template (Settings → Email templates). The token is the long random hex from the email link.

POST /api/v1/auth/<col>/request-password-reset { "email": "..." }
→ always 200 (no enumeration), even if email isn't registered
POST /api/v1/auth/<col>/confirm-password-reset { "token": "...", "password": "..." }

password must meet the password policy (12 characters minimum by default). reset template, 1-hour token TTL. A successful reset signs out every existing session of that user.

POST /api/v1/auth/<col>/request-email-change { "newEmail": "..." } ← user token required
POST /api/v1/auth/<col>/confirm-email-change { "token": "...", "password": "..." }

The request emails a link to the new address (email_change template, 1-hour TTL). Confirming needs that token and the user’s current password, so a stolen session alone can’t move an account to someone else’s inbox. A newer request voids older links. On success the new email is marked verified and every existing session ends, so the user signs in again.

Requests are limited to one a minute per user (429). If the new address already belongs to another account, the request still answers { "sent": true } but no link is sent, so the endpoint can’t be used to find out who has an account. Wrong passwords on confirm count towards the sign-in lockout.

Users who signed up with OAuth2 have no password they know. They set one first with the password reset flow. Anonymous accounts use promote instead.

POST /api/v1/admin/users/<col>/invite { "email": "...", "data": { ... } } ← admin token
POST /api/v1/auth/<col>/accept-invite { "token": "...", "password": "..." }

An admin creates the user (custom fields in data; the beforeCreate / afterCreate hooks run) without a usable password, and an invite email goes out with a 7-day link. Accepting sets the password and verifies the email. Until then the user can’t sign in. Records → Invite user does the same from the admin. 409 if the email is taken; 502 (with the new user’s id) if the user was created but the email failed to send.

If you don’t build these screens yourself, the email links can point at pages vaultbase serves: /auth/verify, /auth/reset, /auth/otp, /auth/confirm-email-change and /auth/invite (each takes token and collection query params). Set App URL (Settings → Email templates) to the vaultbase origin to use them.

Gated by Settings → Auth features → OTP / magic link.

POST /api/v1/auth/<col>/otp/request { "email": "..." } ← always 200
POST /api/v1/auth/<col>/otp/auth { "token": "..." }
POST /api/v1/auth/<col>/otp/auth { "email": "...", "code": "<6 digits>" }

Either token (from the link) or code (from the email) authenticates. 10-minute expiry. SMTP must be configured.

Gated by Settings → Auth features → MFA / TOTP.

POST /api/v1/auth/<col>/totp/setup ← user JWT
→ { "data": { "secret": "<base32>", "otpauth_url": "otpauth://..." } }
POST /api/v1/auth/<col>/totp/confirm { "code": "<6 digits>" }
→ enables MFA on this user; returns { enabled, recovery_codes }
# /totp/setup answers 409 while MFA is already enabled — disable it with a
# current code first.
POST /api/v1/auth/<col>/totp/disable { "code": "<6 digits>" }
→ clears MFA on this user

disable is intentionally not gated by the feature flag — once a user has MFA enabled, disabling it shouldn’t break when an admin turns the feature off globally. Same for /login/mfa. /disable also wipes any stored recovery codes for the user.

POST /api/v1/auth/<col>/totp/recovery/regenerate ← user JWT
{ "code": "<current 6-digit TOTP code>" }
→ { "data": { "codes": ["ABCD-EF23", "GH45-JKLM", ...] } } // 10 codes, plaintext, ONCE
GET /api/v1/auth/<col>/totp/recovery/status ← user JWT
→ { "data": { "total": 10, "remaining": 7 } }

10 single-use 8-character codes per user, formatted XXXX-XXXX (uppercase letters and digits, no 0/O/1/I), hashed at rest. Plaintext is returned once at generation time — there’s no way to retrieve them again. The first batch comes back from /totp/confirm as recovery_codes when MFA is switched on. Regenerating needs a current authenticator code (a session alone isn’t enough) and invalidates the previous batch.

GET /api/v1/auth/<col>/oauth2/providers
→ { "data": [ { "name": "google", "displayName": "Google" }, ... ] }
GET /api/v1/auth/<col>/oauth2/authorize
?provider=google&redirectUri=https://app.example.com/callback&state=<csrf>
→ { "data": { "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "<csrf>", "state_secret": "...", "pkce": "none" } }
POST /api/v1/auth/<col>/oauth2/exchange
{ "provider": "google", "code": "...", "redirectUri": "...",
"state": "<state from /authorize>", "state_secret": "<state_secret from /authorize>" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "..." } } }

state and state_secret are required on /exchange (400 otherwise). Configure providers at Settings → OAuth2 (16 supported out of the box). See OAuth2 API for the full flow.

POST /api/v1/auth/<col>/anonymous
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "anon_xxx@anonymous.invalid", "anonymous": true } } }

30-day JWT carrying anonymous: true claim. Needs anonymous sign-in turned on, and a create_rule that allows the sign-up (403 otherwise).

POST /api/v1/auth/<col>/promote ← anonymous user JWT
{ "email": "alice@x.com", "password": "correct-horse-battery" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "alice@x.com" } } }

Upgrades an existing anonymous record in place — keeps the user id, sets the email + password, clears is_anonymous, and re-issues a fresh non-anonymous JWT. Returns 422 if the caller’s token isn’t anonymous (or the body fails validation or the password policy), 403 if the collection’s create_rule rejects the body, 409 if the email is already taken in this collection.

POST /api/v1/admin/impersonate/<col>/<userId> ← admin auth required
→ { "data": { "token": "<jwt>", "record": { ... }, "impersonated_by": "<admin_id>" } }

1-hour user JWT carrying impersonated_by: <admin_id> for audit.

GET /api/v1/<col>?page=1&perPage=30 # list (records flow)
GET /api/v1/<col>/<id> # one
PATCH /api/v1/<col>/<id> # update non-credential fields
DELETE /api/v1/<col>/<id>

v0.11 promoted auth collections to first-class — admins manage users through the same records endpoints as any other collection. The legacy /api/v1/admin/users/<col> endpoint was removed.

Auth-system columns (password_hash, totp_secret, password_reset_at) never appear in API responses, and incoming PATCH bodies that try to set them are silently stripped — credential changes go through the dedicated auth flows (/totp/disable, /confirm-password-reset, …). Creating a user via POST /api/v1/<auth-col> is rejected; use POST /api/v1/auth/<col>/register instead so passwords get properly hashed and verification email gets sent.

User JWT (audience "user"):

{
"iat": 1730000000,
"exp": 1730604800,
"aud": "user",
"id": "<user_id>",
"email": "...",
"collection": "<col_name>",
"anonymous": true, // optional
"impersonated_by": "<admin_id>" // optional
}

Admin JWT (audience "admin"):

{
"iat": 1730000000,
"exp": 1730604800,
"aud": "admin",
"id": "<admin_id>",
"email": "..."
}