Skip to content

Authentication

Vaultbase ships seven auth flows. All of them issue user JWTs (audience "user") signed with VAULTBASE_JWT_SECRET. Endpoints live under /api/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/auth/<col>/register
{ "email": "alice@x.com", "password": "secret123" }
POST /api/auth/<col>/login
{ "email": "alice@x.com", "password": "secret123" }

/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/auth/<col>/login/mfa with { mfa_token, code }.

Both reuse the SMTP setup (Settings → SMTP). On registration, if SMTP is configured, a verification email is sent best-effort.

POST /api/auth/<col>/request-verify (auth required)
POST /api/auth/<col>/verify-email { token }
POST /api/auth/<col>/request-password-reset { email } ← always 200
POST /api/auth/<col>/confirm-password-reset { token, password }

Templates are editable at Settings → Email templates. Variables: {{email}}, {{token}}, {{link}}, {{appUrl}}, {{collection}}.

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/auth/<col>/oauth2/providers
GET /api/auth/<col>/oauth2/authorize?provider=google&redirectUri=...&state=...
POST /api/auth/<col>/oauth2/exchange { provider, code, redirectUri }
DELETE /api/auth/<col>/oauth2/<provider>/unlink ← user JWT

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 → create link, log in.
  3. Else create a fresh user (random unguessable hash) + link.

The email-verified gate 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/auth/<col>/otp/request { email } ← always 200
POST /api/auth/<col>/otp/auth { token } | { email, code }

Emails use the otp template (Settings → Email templates). 10-minute expiry. SMTP must be configured.

RFC 6238 (HMAC-SHA1, 30-second step, 6-digit codes) with ±1 step drift tolerance.

POST /api/auth/<col>/totp/setup (auth) → { secret, otpauth_url }
POST /api/auth/<col>/totp/confirm { code }
POST /api/auth/<col>/totp/disable { code }

Once totp_enabled = 1, /login returns { mfa_required, mfa_token } instead of a full token, and finishing requires POST .../login/mfa.

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. They’re bcrypt-hashed in vaultbase_mfa_recovery_codes; only the plaintext returned at generation time can authenticate.

POST /api/auth/<col>/totp/recovery/regenerate ← user JWT
→ { "data": { "codes": ["a1b2c3d4", "e5f6g7h8", ...] } } // 10 codes, plaintext, ONCE
GET /api/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/auth/<col>/login/mfa
{ "mfa_token": "...", "recovery_code": "a1b2c3d4" }

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/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/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/auth/<col>/promote ← anonymous user JWT
{ "email": "alice@x.com", "password": "secret123" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "alice@x.com", "anonymous": false } } }
CodeCause
401Missing JWT
403Caller is not anonymous (already a real account)
409Email is already taken in this collection
422Validation 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":"secret123"}' \
https://api.example.com/api/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: 5, max: 64, both /register and PATCH /api/records/users/:id reject a@b.c:

Terminal window
curl -X POST -H "Content-Type: application/json" \
-d '{"email":"a@b.c","password":"secret123"}' \
https://api.example.com/api/auth/users/register
# 422 { "error": "validation failed", "details": { "email": "must be at least 5 characters" } }

Extra body keys land in the record’s data blob, validated against whatever schema you’ve defined.

Admin mints a 1-hour user JWT for any user — for support purposes:

POST /api/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/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. Malformed or out-of-range values fall back to the per-kind default — auth never breaks because of a bad setting.

Refresh ratchet: POST /api/auth/refresh re-mints with the current configured window, so a session “ratchets forward” each refresh.

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