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

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.
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. The generic oidc provider uses the keys listed in the Generic OIDC section above.

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