Authentication
Email/password, OAuth2, OTP / magic link, MFA / TOTP, anonymous, and admin impersonation — what's available and how it fits together.
Vaultbase ships seven auth flows. All of them issue user JWTs (audience
"user") signed with VAULTBASE_JWT_SECRET. Endpoints live under
/api/v1/auth/<collection>/... and require the collection to have
type: "auth".
Feature flags
Section titled “Feature flags”Every flow except plain email/password is gated by a settings flag — toggle them on Settings → Auth features in the admin. Defaults are conservative:
| Feature | Default | Rationale |
|---|---|---|
| OTP / magic link | off | broadens attack surface; opt-in |
| MFA / TOTP | on | no harm if unused |
| Anonymous sign-in | off | guests should be explicit |
| Admin impersonation | on | admin-only by definition |
A disabled feature returns 422 with a clear message.
Email + password
Section titled “Email + password”POST /api/v1/auth/<col>/register{ "email": "alice@x.com", "password": "correct-horse-battery" }
POST /api/v1/auth/<col>/login{ "email": "alice@x.com", "password": "correct-horse-battery" }/register is allowed only if the collection’s create_rule lets the caller
sign up: null is open sign-up, "" (the default for new auth collections)
means no self-registration, and an expression is evaluated against the sign-up
fields. The same rule gates anonymous sign-in, promoting a guest account and
OAuth2 sign-up. See API rules → Sign-up.
/login returns either:
{ "data": { "token": "...", "record": { "id": "...", "email": "..." } } }or, when MFA is enabled on this account:
{ "data": { "mfa_required": true, "mfa_token": "..." } }Finish by POST /api/v1/auth/<col>/login/mfa with { mfa_token, code }.
Password policy
Section titled “Password policy”Both /register and admin POST /api/v1/admin/admins enforce a configurable
policy. Defaults: 12 characters minimum, no character-class requirements,
no breach check.
Edit at Settings → Password policy. Available knobs:
- Minimum length (floor 8 — NIST 800-63B baseline)
- Require uppercase / lowercase / digit / symbol (off by default — modern guidance favors length over class complexity)
- HIBP k-anonymity check against
api.pwnedpasswords.com(only the first 5 SHA-1 characters of the password leave the server). Fails open on network error so a downed API doesn’t lock signups.
See Settings → Password policy keys.
Brute-force lockout
Section titled “Brute-force lockout”On by default: 10 failed sign-ins within 15 minutes. Configure at
Settings → Security → Brute-force lockout (auth.lockout.max_attempts,
auth.lockout.duration_seconds); max_attempts = 0 turns it off.
It covers admin sign-in and POST /api/v1/auth/<col>/login on every auth
collection, plus the password checks in confirm-email-change and
oauth2/merge-confirm. Failures are tracked per account (admins and each
collection’s users separately) and per IP (IP keying needs
security.trusted_proxies). An IP is allowed five times max_attempts,
counted separately for admin and user sign-in, since one address often
fronts many people.
Hitting max_attempts within the window returns
429 Too many failed attempts. Try again later. Each attempt is counted
before the password is checked, so parallel attempts can’t slip past the
limit, and a successful attempt by a different account from the same IP
doesn’t leak signal.
A successful login clears the account’s failures and doesn’t count against
the IP; earlier failures from the IP are kept. vaultbase setup-admin --force
clears an admin’s lockout when it resets the password.
One-time codes have their own fixed limits, independent of this setting: each OTP code and MFA ticket allows 5 attempts, and an account allows 10 failed OTP codes and 10 failed TOTP / recovery codes per 15 minutes.
Email verification & password reset
Section titled “Email verification & password reset”Both reuse the SMTP setup (Settings → SMTP). On registration, if SMTP is configured, a verification email is sent best-effort.
POST /api/v1/auth/<col>/request-verify (auth required)POST /api/v1/auth/<col>/verify-email { token }
POST /api/v1/auth/<col>/request-password-reset { email } ← always 200POST /api/v1/auth/<col>/confirm-password-reset { token, password }Each user gets at most one email of each kind (verification, reset, OTP,
email change) per minute. Extra requests still answer 200 but send nothing,
so the response never reveals whether an address is registered;
request-verify and request-email-change, which need a signed-in user,
answer 429 instead.
All five emails (verification, password reset, email change, invitation and
sign-in code) are editable at Settings → Email templates, each with a
Restore default button. Variables: {{email}}, {{token}}, {{link}},
{{appUrl}}, {{collection}}, and {{code}} in the sign-in code email. In
the email-change message {{email}} is the new address; in the invitation,
the invited one.
OAuth2 providers
Section titled “OAuth2 providers”Built-in: Google, GitHub, GitLab, Facebook, Microsoft, Discord, Twitch, Spotify, LinkedIn, Slack, Bitbucket, Notion, Patreon, Apple, Twitter / X, plus a generic OIDC connector (Auth0, Keycloak, Okta, anything OIDC).
Configure each at Settings → OAuth2. The flow is caller-driven (your app handles the popup + state):
GET /api/v1/auth/<col>/oauth2/providersGET /api/v1/auth/<col>/oauth2/authorize?provider=google&redirectUri=...&state=...POST /api/v1/auth/<col>/oauth2/exchange { provider, code, redirectUri, state, state_secret }POST /api/v1/auth/<col>/oauth2/merge-confirm { merge_token, password }DELETE /api/v1/auth/<col>/oauth2/<provider>/unlink ← user JWT/authorize returns state and state_secret; /exchange requires both
back (400 otherwise).
Account-link strategy on exchange:
- Existing
oauth_linksrow for(provider, provider_user_id)→ log in linked user. - If profile says
emailVerifiedand email matches an existing user in this collection → return{ merge_required: true, merge_token }. The user proves they own the account (password or a user JWT) at/oauth2/merge-confirm, and only then is the provider linked. - Else create a fresh user (random unguessable hash) + link.
The email-verified gate plus the explicit merge prevents takeover via unverified emails at the IdP.
PKCE is supported in two modes (server-managed via ?use_pkce=1, or
client-managed by passing your own code_challenge + code_verifier).
Twitter / X auto-engages PKCE — no opt-in required there.
Unlink is user-bound — only removes the caller’s own link. If the user
has no password and no other OAuth links, unlinking is rejected with 409
to avoid lockout.
See OAuth2 API for PKCE flows, the unlink endpoint, and provider-specific setup (Apple, X, generic OIDC).
OTP / magic link
Section titled “OTP / magic link”A single record carries both a 32-byte URL token and a 6-digit code; either can authenticate.
POST /api/v1/auth/<col>/otp/request { email } ← always 200POST /api/v1/auth/<col>/otp/auth { token } | { email, code }Emails use the otp template (Settings → Email templates). 10-minute expiry.
SMTP must be configured. Requesting a new code voids the previous code and
link. Each code allows 5 attempts; after 10 failed codes for an address in
15 minutes, otp/auth returns 429 until the window passes.
MFA / TOTP
Section titled “MFA / TOTP”RFC 6238 (HMAC-SHA1, 30-second step, 6-digit codes) with ±1 step drift tolerance.
POST /api/v1/auth/<col>/totp/setup (auth) → { secret, otpauth_url }POST /api/v1/auth/<col>/totp/confirm { code }POST /api/v1/auth/<col>/totp/disable { code }/totp/setup answers 409 while MFA is already on: to switch
authenticators, disable with a current code, then set up and confirm again.
Once totp_enabled = 1, /login returns { mfa_required, mfa_token }
instead of a full token, and finishing requires POST .../login/mfa.
Each TOTP code works once: a code that has just been used is refused if sent
again. Failed codes count per account across every ticket (10 per 15 minutes,
shared with /totp/disable), so signing in again doesn’t reset the limit.
Render the otpauth_url as a QR code in your app — any authenticator
(Google Authenticator, 1Password, Authy, Bitwarden) can scan it.
Recovery codes
Section titled “Recovery codes”When TOTP is enrolled, Vaultbase issues 10 single-use 8-character recovery
codes, formatted XXXX-XXXX. The first batch comes back from
/totp/confirm as recovery_codes. They’re argon2id-hashed in
vaultbase_mfa_recovery_codes; only the plaintext returned at generation
time can authenticate.
POST /api/v1/auth/<col>/totp/recovery/regenerate ← user JWT + { 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 } }POST .../login/mfa accepts either code (current TOTP) or
recovery_code — the latter is consumed (single-use) and decrements the
remaining count.
POST /api/v1/auth/<col>/login/mfa{ "mfa_token": "...", "recovery_code": "ABCD-EF23" }POST /totp/disable wipes all stored recovery codes alongside the secret —
re-enrolling generates a fresh batch.
Anonymous sessions
Section titled “Anonymous sessions”Mints a guest user with a synthetic email (anon_<id>@anonymous.invalid),
unguessable hash, and a configurable-window JWT carrying anonymous: true.
Useful for guest carts, onboarding flows, or rate-limited public APIs.
POST /api/v1/auth/<col>/anonymousThe window defaults to 30 days. Tune it from Settings → Auth features → Session lifetimes, or via the settings key:
# Cut anonymous sessions to 24 hourscurl -X PATCH /api/v1/admin/settings \ -H "Authorization: Bearer $ADMIN" \ -d '{"auth.anonymous.window_seconds": "86400"}'Bounds: minimum 60 seconds, maximum 365 days. Invalid values fall back to the default. Changing the window only affects newly issued tokens — existing ones keep their original expiry.
Promote an anonymous user to a real account
Section titled “Promote an anonymous user to a real account”When a guest decides to sign up, POST /promote upgrades the existing record
in place — preserving its id, related records, and any data already keyed
to it. Sets email + password, clears is_anonymous, and returns a
fresh non-anonymous JWT.
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" } } }| Code | Cause |
|---|---|
401 | Missing JWT |
403 | The collection’s create_rule rejects the sign-up |
409 | Email is already taken in this collection |
422 | Caller is not anonymous (already a real account), or validation failed (invalid email, weak password) |
curl -X POST \ -H "Authorization: Bearer $ANON_JWT" \ -H "Content-Type: application/json" \ -d '{"email":"alice@x.com","password":"correct-horse-battery"}' \ https://api.example.com/api/v1/auth/users/promoteRegister validation
Section titled “Register validation”/register doesn’t just check email + password — it runs the full
validateRecord pipeline against the auth collection’s implicit fields
plus any user-defined fields. So min/max/pattern constraints on
custom fields (or even on email) are enforced consistently with /records.
For example, if users.email carries min: 10, max: 64, /register
rejects a@b.co:
curl -X POST -H "Content-Type: application/json" \ -d '{"email":"a@b.co","password":"correct-horse-battery"}' \ https://api.example.com/api/v1/auth/users/register# 422 { "error": "Validation failed", "code": 422, "details": { "email": "email must be at least 10 characters" } }Extra body keys set the collection’s own fields and are validated against them; keys that aren’t fields of the collection are ignored.
Admin impersonation
Section titled “Admin impersonation”Admin mints a 1-hour user JWT for any user — for support purposes:
POST /api/v1/admin/impersonate/<col>/<userId> (admin auth required) → { "data": { "token": "<jwt>", "impersonated_by": "<admin_id>" } }The minted user JWT carries impersonated_by: <admin_id> for audit. Every
request made with this token is tagged with auth_impersonated_by in the
log entry, so you can later reconstruct who was acting on the user’s behalf
(see Logging).
Admin UI exposes this as an Impersonate button in the auth-user drawer that copies the token to clipboard.
Token shape
Section titled “Token shape”User tokens are signed JWTs:
{ iat: 1730000000, exp: 1730604800, // configurable per kind — see Session lifetimes aud: "user", // "admin" for admin tokens id: "<user_id>", email: "alice@x.com", collection: "users", // optional: anonymous?: true, impersonated_by?: "<admin_id>"}Pass on every request as Authorization: Bearer <jwt>. Refresh via
POST /api/v1/auth/refresh (works for user + admin tokens).
Session lifetimes
Section titled “Session lifetimes”Every JWT kind has its own configurable expiry window, settable from Settings → Auth features → Session lifetimes or directly via settings keys:
| Kind | Setting key | Default |
|---|---|---|
user | auth.user.window_seconds | 7d |
admin | auth.admin.window_seconds | 7d |
anonymous | auth.anonymous.window_seconds | 30d |
impersonate | auth.impersonate.window_seconds | 1h |
refresh | auth.refresh.window_seconds | 7d |
file | auth.file.window_seconds | 1h |
Bounds: 60 seconds minimum, 365 days maximum. Saving a value outside
these bounds through the settings API is refused with 422. A malformed
stored value falls back to the per-kind default, and one above 365 days is
treated as 365 days — auth never breaks because of a bad setting.
Refresh ratchet: POST /api/v1/auth/refresh re-mints with the current
refresh window, so a session “ratchets forward” each refresh — up to
auth.session.max_seconds (default 30 days, 0 = no cap) from the original
sign-in. Past that, /refresh returns 401 and the user signs in again.
Anonymous sessions are exempt from the cap.
Existing sessions are unaffected when you change a window — new mints
only. To revoke active sessions immediately, rotate
<dataDir>/.secret and restart (logs everyone out at once).