Skip to content

OAuth2 API

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

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

GET /api/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?..." } }
ParamNotes
providerOne of the providers from /providers.
redirectUriMust exactly match what’s registered with the IdP.
stateYour CSRF token. The IdP echoes it back; verify on exchange.

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/auth/<col>/oauth2/exchange
{ "provider": "google",
"code": "<from the IdP redirect>",
"redirectUri": "https://app.example.com/auth/callback" }
→ { "data": { "token": "<jwt>", "record": { "id": "...", "email": "..." } } }

The server:

  1. Exchanges code for an IdP access token.
  2. Fetches the user profile (provider-specific endpoint).
  3. Looks for an existing oauth_links row for (provider, provider_user_id):
    • Found → log in the linked user.
  4. 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.
  5. Else, create a new user with a synthetic email + unguessable hash, link it, log in.

The email-verified gate plus explicit-consent merge prevents IdP-trust account takeover.

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

CodeCause
400Missing or malformed param
422Provider not enabled, exchange rejected by IdP, profile fetch failed
502IdP returned an unexpected error response

The response body always includes a details field with the IdP’s raw error message when relevant — easier to debug “why did Discord say invalid_grant”.

Vaultbase doesn’t track state server-side — it’s threaded through the client-side flow. Generate a random nonce, stash it in sessionStorage, include it on authorize, verify on the redirect, then pass code to exchange.

// Sign in with Google
const state = crypto.randomUUID();
sessionStorage.setItem("oauth_state", state);
const { data } = await fetch(
`/api/auth/users/oauth2/authorize?provider=google&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}`,
).then(r => r.json());
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/auth/users/oauth2/exchange", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: "google", code: params.get("code"), 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 in vaultbase_auth_tokens keyed by your state (10-minute TTL, single-use). On /exchange you pass the same state and the server retrieves the verifier transparently.

GET /api/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=..." } }
POST /api/auth/<col>/oauth2/exchange
{ "provider": "google",
"code": "<from redirect>",
"redirectUri": "https://app.example.com/auth/callback",
"state": "<same state from authorize>" }
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/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/auth/users/oauth2/exchange", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: "google", code, 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/auth/<col>/oauth2/<provider>/unlink
Authorization: Bearer <user-jwt>
→ { "data": { "unlinked": "google" } }

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/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.
GitLabSelf-hosted GitLab works — set oauth2.gitlab.endpoint if not gitlab.com.
MicrosoftTenant-flexible; uses common by default.
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. Email gated behind elevated access — provider_email may be null.
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. Email access requires elevated / enterprise tier; basic-tier apps will see provider_email: null.

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/admin/settings.