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.
Built-in providers
Section titled “Built-in providers”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.
List enabled providers
Section titled “List enabled providers”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"ANDoauth2.<name>.client_idis non-empty ANDoauth2.<name>.client_secretis 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 an authorize URL
Section titled “Get an authorize URL”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>" } }| Param | Notes |
|---|---|
provider | One of the providers from /providers. |
redirectUri | Must exactly match what’s registered with the IdP, and be allowed by oauth2.allowed_redirect_uris (see below). |
state | Optional. 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=....
Exchange code for a token
Section titled “Exchange code for a token”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:
- Checks
stateandstate_secret: they must have come from the same/authorizecall, for the same provider andredirectUri, within 10 minutes, and are spent here (400otherwise). For Apple, Google and OIDC, the id_token must also carry thenoncethe server sent with the authorize URL. - Exchanges
codefor an IdP access token. - Fetches the user profile (provider-specific endpoint).
- Looks for an existing
oauth_linksrow for(provider, provider_user_id):- Found → log in the linked user.
- Else, if profile says
emailVerified = trueand 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.
- Returns
- 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.invalidaddress 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.
Merge-confirm — link an existing account to a new provider
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-confirmAuthorization: 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.
Errors
Section titled “Errors”| Code | Cause |
|---|---|
400 | Missing, used or expired state / state_secret; the IdP rejected the code exchange; profile fetch failed or returned no id / email |
403 | New account, but the collection’s create_rule doesn’t allow the sign-up |
422 | Unknown 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”.
State / CSRF
Section titled “State / CSRF”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 Googleconst 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 (Proof Key for Code Exchange)
Section titled “PKCE (Proof Key for Code Exchange)”PKCE protects the auth-code exchange from interception. Vaultbase supports two modes — pick the one that matches your client architecture.
Server-managed (recommended for confidential clients)
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.
Client-managed (recommended for public / SPA / native clients)
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 locallyconst code_verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));const code_challenge = base64url( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code_verifier)));
// 2. Authorizeconst 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 verifierawait 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 mode | When |
|---|---|
use_pkce=1 (server-managed) | Confidential clients, server-to-server. Less code in the browser. |
| Bring-your-own challenge / verifier | Public clients (SPAs, mobile, desktop) where the client secret can’t be trusted. |
Unlink an OAuth provider
Section titled “Unlink an OAuth provider”DELETE /api/v1/auth/<col>/oauth2/<provider>/unlinkAuthorization: Bearer <user-jwt> → { "data": null }User-bound — only unlinks the caller’s own link. Returns 404 if no link
exists for (user, provider).
curl -X DELETE \ -H "Authorization: Bearer $USER_JWT" \ https://api.example.com/api/v1/auth/users/oauth2/google/unlinkProvider-specific notes
Section titled “Provider-specific notes”| Provider | Notes |
|---|---|
Standard OIDC. email_verified from id_token. | |
| GitHub | Email may be private — Vaultbase calls /user/emails and picks the verified primary. |
| GitLab | gitlab.com only — the endpoints aren’t configurable, so self-hosted GitLab isn’t supported. The email is treated as unverified. |
| Microsoft | Tenant-flexible; uses common by default. The email is set by the tenant, so it is treated as unverified. |
| Discord | Email always returned, verified flag respected. |
| Slack | Workspace-scoped; emails are reliable. |
| Apple | JWT-signed client_secret (ES256, 14-min cache). response_mode=form_post. email_verified honored from id_token. |
| Twitter / X | PKCE auto-engaged. No email is requested: the link stores <username>@twitter.invalid and the new account gets a placeholder …@oauth.invalid address. |
| OIDC | Single instance per deploy. Plug in any OIDC-conformant IdP — Auth0, Keycloak, Okta, etc. |
Apple Sign In setup
Section titled “Apple Sign In setup”Required settings:
| Key | Where to find it |
|---|---|
oauth2.apple.client_id | Services ID (e.g. com.acme.web) — Apple Developer → Certificates, Identifiers & Profiles → Identifiers → Services IDs. |
oauth2.apple.team_id | 10-char Team ID — top-right of the Apple Developer portal. |
oauth2.apple.key_id | Key ID — Keys → ”+” → Sign in with Apple. |
oauth2.apple.private_key | The .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.
Twitter / X setup
Section titled “Twitter / X setup”| Key | Where to find it |
|---|---|
oauth2.twitter.client_id | X Developer Portal → Project → User authentication settings → OAuth 2.0 Client ID. |
oauth2.twitter.client_secret | Same 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.
Generic OIDC setup
Section titled “Generic OIDC setup”One generic OIDC connector ships per deploy. Useful for Auth0, Keycloak, Okta, Authentik, ZITADEL, or any IdP exposing the standard discovery endpoints.
| Key | Notes |
|---|---|
oauth2.oidc.enabled | "1" / "0" |
oauth2.oidc.client_id | from your IdP’s app/client registration |
oauth2.oidc.client_secret | from your IdP’s app/client registration |
oauth2.oidc.authorization_url | e.g. https://acme.eu.auth0.com/authorize |
oauth2.oidc.token_url | e.g. https://acme.eu.auth0.com/oauth/token |
oauth2.oidc.userinfo_url | e.g. https://acme.eu.auth0.com/userinfo |
oauth2.oidc.scopes | space-separated; default openid profile email |
oauth2.oidc.display_name | label rendered in the providers list |
Settings keys
Section titled “Settings keys”For each provider name in
google github gitlab facebook microsoft discord twitch spotify linkedin slack bitbucket notion patreon twitter:
| Key | Notes |
|---|---|
oauth2.<name>.enabled | "1" / "0" |
oauth2.<name>.client_id | from the IdP console |
oauth2.<name>.client_secret | from 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.