Skip to content

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".

Every flow except plain email/password is gated by a settings flag — toggle them on Settings → Auth features in the admin. Defaults are conservative:

FeatureDefaultRationale
OTP / magic linkoffbroadens attack surface; opt-in
MFA / TOTPonno harm if unused
Anonymous sign-inoffguests should be explicit
Admin impersonationonadmin-only by definition

A disabled feature returns 422 with a clear message.

POST /api/v1/auth/<col>/register
{ "email": "[email protected]", "password": "correct-horse-battery" }
POST /api/v1/auth/<col>/login
{ "email": "[email protected]", "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 }.

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.

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, an IPv6 client by its /64. Behind a reverse proxy, IP keying needs security.trusted_proxies; without it, failures from a proxy on the same host count by account only. 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.

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 200
POST /api/v1/auth/<col>/confirm-password-reset { token, password }

Each link and code works once: two requests racing one link can’t both use it. A password reset or a confirmed email change also ends the user’s other open links and codes (older reset links, sign-in codes, links sent to the old address), since 0.36.0. It also ends the user’s sessions, voids a pending provider-merge token, and disables the user’s registered push devices (/api/v1/notifications/devices) until their apps register again at sign-in.

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.

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/providers
GET /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:

  1. Existing oauth_links row for (provider, provider_user_id) → log in linked user.
  2. If profile says emailVerified and 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.
  3. 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. A password reset on the account voids a pending merge_token, and a merge that races a reset is refused.

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).

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 200
POST /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.

A successful otp/auth marks the email verified. On an account that wasn’t verified yet, whoever registered it never proved they own the address, so the first OTP sign-in also ends the password, TOTP and recovery codes, sessions, provider links and push devices set up before it. The owner can set a password through a password reset. Their apps register push devices again at sign-in.

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.

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.

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>/anonymous

The window defaults to 30 days. Tune it from Settings → Auth features → Session lifetimes, or via the settings key:

Terminal window
# Cut anonymous sessions to 24 hours
curl -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": "[email protected]", "password": "correct-horse-battery" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "[email protected]" } } }
CodeCause
401Missing JWT
403The collection’s create_rule rejects the sign-up
409Email is already taken in this collection
422Caller is not anonymous (already a real account), or validation failed (invalid email, weak password)
Terminal window
curl -X POST \
-H "Authorization: Bearer $ANON_JWT" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"correct-horse-battery"}' \
https://api.example.com/api/v1/auth/users/promote

/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 [email protected]:

Terminal window
curl -X POST -H "Content-Type: application/json" \
-d '{"email":"[email protected]","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.

Values are stored as a record write stores them: json and multi-select values as JSON, encrypted fields encrypted, password fields hashed, and autodate fields set by the server. A beforeCreate hook’s changes are validated before they’re stored, and an email the hook sets is the account’s address (checked like the body’s). The hook runs before the address is looked up, so a payload it refuses gets the same 422 whether or not the address has an account. Invitations from the admin are checked the same way, but only for the fields they set. OAuth sign-up, guest sign-in and guest upgrade run the hook too; see sign-ups on auth collections.

required is checked wherever a body is validated (/register, guest upgrade, record updates). Guest sign-up, OAuth sign-up and invitations make users without the collection’s fields, so a required field can be empty on those users until it’s set.

Before 0.31.1, a field marked required when its auth collection was created couldn’t be empty in the database, so guest sign-up, OAuth sign-up and invitations failed with an error. On Postgres the server removes that limit when it starts. On SQLite it can’t be removed in place, and vaultbase doctor names the fields that have it (the server also logs them when it starts). To move a field, here name on users, to a column without the limit and keep its values:

  1. In the schema editor, add a field name_new, not required, and save.

  2. Stop the server, copy data.db (and data.db-wal if it’s there) as a backup, then copy the values over:

    Terminal window
    sqlite3 data.db 'UPDATE vb_users SET name_new = name'

    Start the server again.

  3. Remove name and save. If a view collection reads name, change the view first: a field a view reads can’t be removed.

  4. Rename name_new to name, mark it required, and save.

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).

The token acts for the admin who minted it: it stops working when that admin is signed out (a password reset or force-logout-all) or deleted, as well as when the user’s own sessions end. An admin whose session was reset since they signed in gets 401 here until they sign in again.

Admin UI exposes this as an Impersonate button in the auth-user drawer that copies the token to clipboard.

User tokens are signed JWTs:

{
iat: 1730000000,
iat_ms: 1730000000123, // issue time in milliseconds
rs: 0, // the account's reset stamp when the token was issued
exp: 1730604800, // configurable per kind — see Session lifetimes
aud: "user", // "admin" for admin tokens
id: "<user_id>",
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).

Every token is checked against the account’s reset stamp (password_reset_at). A password reset, an email change, a promoted guest, an admin password change or force-logout-all changes the stamp, and every token whose rs differs from it stops working at once. Tokens without rs end when the stamp is later than their iat_ms (or iat).

Every way of signing in carries the stamp: password, magic link and OTP, OAuth2 providers, API tokens, the MFA step after any of them, and the admin’s side of an impersonation. A reset that races a sign-in still ends the session it makes, on any server.

Every JWT kind has its own configurable expiry window, settable from Settings → Auth features → Session lifetimes or directly via settings keys:

KindSetting keyDefault
userauth.user.window_seconds7d
adminauth.admin.window_seconds7d
anonymousauth.anonymous.window_seconds30d
impersonateauth.impersonate.window_seconds1h
refreshauth.refresh.window_seconds7d
fileauth.file.window_seconds1h

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).