Skip to content

OAuth2 API

Endpoints for sign-in via Google, GitHub, Apple, X, generic OIDC, and 11 other providers — list, authorize, exchange, unlink.

OAuth2 is built into every auth collection. Configure providers at Settings → OAuth2; the flow is then caller-driven (your frontend handles the popup and CSRF state).

For setup steps and the high-level flow see Authentication.

Google, GitHub, GitLab, Facebook, Microsoft, Discord, Twitch, Spotify, LinkedIn, Slack, Bitbucket, Notion, Patreon, Apple, Twitter / X, and a generic OIDC connector (Auth0, Keycloak, Okta, anything OIDC-conformant).

Each requires a client ID + client secret from the provider’s developer console. Vaultbase wires up the rest — endpoint URLs, scopes, profile-URL parsing, the email-verified gate.

GET /api/v1/auth/<col>/oauth2/providers
→ { "data": [
{ "name": "google", "displayName": "Google" },
{ "name": "github", "displayName": "GitHub" }
] }

A provider counts as enabled only when:

  • oauth2.<name>.enabled = "1" AND
  • oauth2.<name>.client_id is non-empty AND
  • oauth2.<name>.client_secret is non-empty

Apple has no client secret: it needs client_id, team_id, key_id and private_key instead. The generic OIDC provider also needs authorization_url and token_url.

Use this on your sign-in page to render only the buttons that are actually configured server-side.

GET /api/v1/auth/<col>/oauth2/authorize
?provider=google
&redirectUri=https://app.example.com/auth/callback
&state=<csrf-token>
→ { "data": { "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "<csrf-token>", "state_secret": "<keep in the browser>" } }
ParamNotes
providerOne of the providers from /providers.
redirectUriMust exactly match what’s registered with the IdP, and be allowed by oauth2.allowed_redirect_uris (see below).
stateOptional. Your CSRF token; if you omit it the server generates one and returns it as data.state. Either way it is recorded server-side and must be sent back to /exchange.

The response also carries state_secret. Keep it in the page that started the flow (e.g. sessionStorage) and send it to /exchange. It never appears in a URL, so a callback planted in someone else’s browser (login CSRF) can’t be completed there.

Allowed redirect URIs. oauth2.<provider>.allowed_redirect_uris (or the global oauth2.allowed_redirect_uris) is a comma-separated list. An entry ending in * matches that origin with any path under it — https://app.example.com* matches https://app.example.com/cb, not https://app.example.com.evil.tld/…. For an app scheme (com.myapp://oauth*) the scheme must match and the URI start with the prefix. With no list configured, only URIs on the origin of app.url are accepted.

The frontend redirects (or pop-ups) to authorize_url. After the user approves, the IdP redirects back to your redirectUri with ?code=...&state=....

POST /api/v1/auth/<col>/oauth2/exchange
{ "provider": "google",
"code": "<from the IdP redirect>",
"redirectUri": "https://app.example.com/auth/callback",
"state": "<the state from /authorize>",
"state_secret": "<the state_secret from /authorize>" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "..." } } }

The server:

  1. Checks state and state_secret: they must have come from the same /authorize call, for the same provider and redirectUri, within 10 minutes, and are spent here (400 otherwise). For Apple, Google and OIDC, the id_token must also carry the nonce the server sent with the authorize URL.
  2. Exchanges code for an IdP access token.
  3. Fetches the user profile (provider-specific endpoint).
  4. Looks for an existing oauth_links row for (provider, provider_user_id) in this auth collection. Each auth collection links its own accounts, so one Google account can sign in to users and to customers as two accounts (since 0.36.0; before, it could be linked in one collection only):
    • Found → log in the linked user.
  5. Else, if profile says emailVerified = true and the email matches an existing user in this collection:
    • Returns { merge_required: true, merge_token, email, provider } — the existing user must consent before we link. Call /merge-confirm (below) to complete.
  6. Else, create a new user with an unguessable hash, link it, log in. If the provider didn’t verify the email (or the address already has an account), the new account gets a placeholder …@oauth.invalid address instead of claiming it; the provider’s address stays on the link.

Emails are matched to existing accounts regardless of case.

If the signed-in account has TOTP enabled, the response is { mfa_required: true, mfa_token } instead of a token — finish with POST .../login/mfa, as for a password sign-in.

The email-verified gate plus explicit-consent merge prevents IdP-trust account takeover. Facebook, Microsoft, GitLab, Twitch, Spotify and Notion don’t tell us whether an address was verified, and Twitter / X gives no address at all, so their emails never trigger a merge.

Section titled “Merge-confirm — link an existing account to a new provider”

When /exchange returns merge_required: true, prove ownership of the existing account and we’ll link the provider:

POST /api/v1/auth/<col>/oauth2/merge-confirm
{ "merge_token": "<from /exchange>",
"password": "<the existing user's password>" }
→ { "data": { "token": "<jwt>", "record": {...}, "linked_provider": "google" } }

Or, if the user is already signed in elsewhere, prove with their JWT instead of a password:

POST /api/v1/auth/<col>/oauth2/merge-confirm
Authorization: Bearer <user-jwt>
{ "merge_token": "<from /exchange>" }

merge_token is single-use, valid for 15 minutes, and bound to the collection it was issued in. Re-using or expiring it returns 401.

If the link already exists (idempotent retry), the call succeeds and just returns a fresh JWT. Proven by password, an account with TOTP on gets { mfa_required, mfa_token } instead, and the provider is linked only when that MFA step completes. Wrong passwords count towards the sign-in lockout. Resetting the password removes the account’s provider links and voids a pending merge_token (401). A merge-confirm, or its MFA step, that races a reset is refused rather than linking the provider again.

CodeCause
400Missing, used or expired state / state_secret; the IdP rejected the code exchange; profile fetch failed or returned no id / email
403New account, but the collection’s create_rule doesn’t allow the sign-up
422Unknown or disabled provider, missing provider / redirectUri, redirectUri not in the allowlist

When the IdP rejects the exchange or the profile fetch, its message is in the error field — easier to debug “why did Discord say invalid_grant”.

Vaultbase records every state it issues and requires it back on /exchange, bound to the provider and redirect URI and usable once. Still check it in the browser too: generate it, stash it in sessionStorage, include it on authorize, compare it on the redirect, then pass code and state to exchange.

// Sign in with Google
const state = crypto.randomUUID();
sessionStorage.setItem("oauth_state", state);
const { data } = await fetch(
`/api/v1/auth/users/oauth2/authorize?provider=google&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}`,
).then(r => r.json());
sessionStorage.setItem("oauth_state_secret", data.state_secret);
window.location.href = data.authorize_url;
// On the redirect target page:
const params = new URLSearchParams(location.search);
if (params.get("state") !== sessionStorage.getItem("oauth_state")) throw new Error("state mismatch");
const { data } = await fetch("/api/v1/auth/users/oauth2/exchange", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "google", code: params.get("code"), state: params.get("state"),
state_secret: sessionStorage.getItem("oauth_state_secret"), redirectUri,
}),
}).then(r => r.json());
localStorage.setItem("token", data.token);

PKCE protects the auth-code exchange from interception. Vaultbase supports two modes — pick the one that matches your client architecture.

Section titled “Server-managed (recommended for confidential clients)”

Append &use_pkce=1 to /authorize. Vaultbase generates a verifier, appends code_challenge + code_challenge_method=S256 to the IdP URL, and stores the verifier with your state (10-minute TTL, single-use). On /exchange you pass the same state and the server retrieves the verifier transparently.

GET /api/v1/auth/<col>/oauth2/authorize
?provider=google
&redirectUri=https://app.example.com/auth/callback
&state=<csrf-token>
&use_pkce=1
→ { "data": { "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?...&code_challenge=...",
"state": "<csrf-token>", "state_secret": "...", "pkce": "server", ... } }
POST /api/v1/auth/<col>/oauth2/exchange
{ "provider": "google",
"code": "<from redirect>",
"redirectUri": "https://app.example.com/auth/callback",
"state": "<same state from authorize>",
"state_secret": "<state_secret from authorize>" }

Without use_pkce=1 or a code_challenge, no PKCE is used (pkce: "none"), except for Twitter / X.

Section titled “Client-managed (recommended for public / SPA / native clients)”

The caller generates the verifier + challenge and passes them through. The server doesn’t see the verifier until /exchange.

// 1. Generate locally
const code_verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const code_challenge = base64url(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code_verifier))
);
// 2. Authorize
const authUrl = `/api/v1/auth/users/oauth2/authorize?provider=google` +
`&redirectUri=${encodeURIComponent(redirectUri)}` +
`&state=${state}&code_challenge=${code_challenge}&code_challenge_method=S256`;
// 3. Exchange — pass your own verifier
await fetch("/api/v1/auth/users/oauth2/exchange", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: "google", code, state, state_secret, redirectUri, code_verifier }),
});
Use modeWhen
use_pkce=1 (server-managed)Confidential clients, server-to-server. Less code in the browser.
Bring-your-own challenge / verifierPublic clients (SPAs, mobile, desktop) where the client secret can’t be trusted.

Native sign-in with an ID token (Apple, Google)

Section titled “Native sign-in with an ID token (Apple, Google)”

A mobile app that signs in with the platform’s own sheet (Sign in with Apple, Google sign-in) gets an ID token from it, not an authorization code. Send that token here instead of going through /authorize and /exchange. Available since v0.28.0.

1. Get a nonce (single-use, valid for 10 minutes, bound to the provider). Required for Apple; optional for Google, whose Flutter plugin sets the nonce once per app launch:

POST /api/v1/auth/<col>/oauth2/id-token/nonce
{ "provider": "apple" }
→ { "data": { "nonce": "<random>", "expires_at": 1761400000 } }

2. Sign in on the device and hand the nonce to the sign-in sheet. The token must carry it, either as given or as its SHA-256 in hex (the usual way with Sign in with Apple).

3. Send the ID token:

POST /api/v1/auth/<col>/oauth2/id-token
{ "provider": "apple",
"id_token": "<from the sign-in sheet>",
"nonce": "<from step 1; may be left out for Google>",
"name": "Ann Lee" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "..." } } }

The answer is the same as /exchange’s: a session, merge_required when the verified email belongs to an existing account (finish with merge-confirm), or mfa_required for an account with TOTP on. Accounts and links are shared with the browser flow: a user who signed in with Google on the web and with Google in the app is the same user.

The server checks the token’s signature against the provider’s published keys, its issuer, its expiry, and its audience: one of the provider’s client_id or native_client_ids. Each token signs in once; a copy sent again is refused, nonce or not. When a nonce is sent, the token must carry it. name is optional; Apple gives the user’s name to the app only on the first sign-in and never puts it in the token, so the app may pass it on. It’s used only when a new account is created.

ProviderAudience to list in oauth2.<p>.native_client_ids
appleThe app’s bundle ID (com.example.app). The Services ID in client_id is accepted too (Sign in with Apple JS).
googleThe iOS and Android OAuth client IDs. The web client ID in client_id is accepted too, and is what Android tokens carry when the app asks for a server client ID.

oauth2.<p>.enabled must be on. The browser flow’s other settings (Apple’s key, Google’s client secret) aren’t needed for this one.

CodeCause
400Apple without a nonce; a nonce this server didn’t issue, or spent, expired or for another provider; or a token without an email
401The token’s signature, issuer, audience or expiry is wrong, it doesn’t carry the nonce that was sent, or it was used before
403A new account would be created and sign-up is closed (the collection’s create rule)
422The provider isn’t apple or google, isn’t enabled, or has no client IDs
DELETE /api/v1/auth/<col>/oauth2/<provider>/unlink
Authorization: Bearer <user-jwt>
→ { "data": null }

User-bound — only unlinks the caller’s own link. Returns 404 if no link exists for (user, provider).

Terminal window
curl -X DELETE \
-H "Authorization: Bearer $USER_JWT" \
https://api.example.com/api/v1/auth/users/oauth2/google/unlink
ProviderNotes
GoogleStandard OIDC. email_verified from id_token.
GitHubEmail may be private — Vaultbase calls /user/emails and picks the verified primary.
GitLabgitlab.com only — the endpoints aren’t configurable, so self-hosted GitLab isn’t supported. The email is treated as unverified.
MicrosoftTenant-flexible; uses common by default. The email is set by the tenant, so it is treated as unverified.
DiscordEmail always returned, verified flag respected.
SlackWorkspace-scoped; emails are reliable.
AppleJWT-signed client_secret (ES256, 14-min cache). response_mode=form_post. email_verified honored from id_token.
Twitter / XPKCE auto-engaged. No email is requested: the link stores <username>@twitter.invalid and the new account gets a placeholder …@oauth.invalid address.
OIDCSingle instance per deploy. Plug in any OIDC-conformant IdP — Auth0, Keycloak, Okta, etc.

Required settings:

KeyWhere to find it
oauth2.apple.client_idServices ID (e.g. com.acme.web) — Apple Developer → Certificates, Identifiers & Profiles → Identifiers → Services IDs.
oauth2.apple.team_id10-char Team ID — top-right of the Apple Developer portal.
oauth2.apple.key_idKey ID — Keys → ”+” → Sign in with Apple.
oauth2.apple.private_keyThe .p8 PEM contents (multi-line).

Vaultbase mints the JWT-signed client_secret per request (ES256), caches it for 14 minutes, and posts it to Apple’s token endpoint. The redirect arrives via response_mode=form_post, so configure the same redirectUri on the Services ID.

KeyWhere to find it
oauth2.twitter.client_idX Developer Portal → Project → User authentication settings → OAuth 2.0 Client ID.
oauth2.twitter.client_secretSame screen. Must be a Confidential client.

Set the redirect URL on the Twitter app to match redirectUri. PKCE is automatic — don’t pass use_pkce=1. Vaultbase doesn’t read the X account’s email, so accounts created through X get a placeholder …@oauth.invalid address.

One generic OIDC connector ships per deploy. Useful for Auth0, Keycloak, Okta, Authentik, ZITADEL, or any IdP exposing the standard discovery endpoints.

KeyNotes
oauth2.oidc.enabled"1" / "0"
oauth2.oidc.client_idfrom your IdP’s app/client registration
oauth2.oidc.client_secretfrom your IdP’s app/client registration
oauth2.oidc.authorization_urle.g. https://acme.eu.auth0.com/authorize
oauth2.oidc.token_urle.g. https://acme.eu.auth0.com/oauth/token
oauth2.oidc.userinfo_urle.g. https://acme.eu.auth0.com/userinfo
oauth2.oidc.scopesspace-separated; default openid profile email
oauth2.oidc.display_namelabel rendered in the providers list

For each provider name in google github gitlab facebook microsoft discord twitch spotify linkedin slack bitbucket notion patreon twitter:

KeyNotes
oauth2.<name>.enabled"1" / "0"
oauth2.<name>.client_idfrom the IdP console
oauth2.<name>.client_secretfrom the IdP console

Apple uses an extended set (see above): client_id, team_id, key_id, private_key. Apple and Google also take native_client_ids for native sign-in. The generic oidc provider uses the keys listed in the Generic OIDC section above.

PATCH them via /api/v1/admin/settings.