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

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

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

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.

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": "alice@x.com", "password": "correct-horse-battery" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "alice@x.com" } } }
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":"alice@x.com","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 a@b.co:

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

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

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