OAuth, Passkeys & Custom Claims
Social login with Google or GitHub, hardware-backed passkeys via WebAuthn, and embedding roles or metadata in JWTs.
What you'll learn
- ✓OAuth 2.0 — GET /v1/auth/oauth/google/authorize → callback → JWT (same shape as password login)
- ✓Required env vars: SAPIX_OAUTH_GOOGLE_CLIENT_ID, _SECRET, SAPIX_OAUTH_REDIRECT_BASE_URL
- ✓SAPIX_OAUTH_ALLOWED_REDIRECT_URIS — required in production to prevent open redirect attacks
- ✓Passkeys (WebAuthn FIDO2) — requires SAPIX_WEBAUTHN_RP_ID + SAPIX_WEBAUTHN_ORIGIN
- ✓Register: POST /v1/auth/passkey/register-challenge (Bearer JWT) → POST /v1/auth/passkey/register
- ✓Authenticate: POST /v1/auth/passkey/authenticate-challenge → POST /v1/auth/passkey/authenticate → JWT
- ✓app_metadata (root-only) vs user_metadata (user-editable) — embedded in JWT at issuance
- ✓PATCH /v1/auth/users/:email/app-metadata — set role, org_id, plan (root key required)
- ✓PATCH /v1/auth/me/user-metadata — user sets their own display_name, prefs
- ✓Metadata changes take effect on next JWT — existing tokens carry old values until expiry
Register a user. Use PATCH /v1/auth/users/:email/app-metadata to set {"role": "admin"}. Log the user out and back in. Decode the new JWT and confirm app_metadata.role is present.
## OAuth, Passkeys & Custom Claims
This lesson extends the User Auth add-on (Lesson 59) with three advanced features: - OAuth 2.0 — let users sign in with Google or GitHub without setting a password - Passkeys — FIDO2/WebAuthn biometric or hardware key authentication - Custom claims — embed application roles and user preferences into JWTs
> Prerequisite: Lesson 58 (register, login, magic links).
---
## OAuth 2.0 (Google & GitHub)
Enable
`bash
# Google
SAPIX_OAUTH_GOOGLE_CLIENT_ID=...
SAPIX_OAUTH_GOOGLE_CLIENT_SECRET=...
# GitHub SAPIX_OAUTH_GITHUB_CLIENT_ID=... SAPIX_OAUTH_GITHUB_CLIENT_SECRET=...
# Comma-separated list of allowed redirect URIs (exact match)
SAPIX_OAUTH_ALLOWED_REDIRECT_URIS=https://yourapp.com/auth/callback,http://localhost:3000/auth/callback
`
Omit a provider's env vars to disable it. Both can be active at the same time.
Flow
Step 1 — Redirect the user to the provider:
`
GET /v1/auth/oauth/google/authorize?redirect_uri=https://yourapp.com/auth/callback
GET /v1/auth/oauth/github/authorize?redirect_uri=https://yourapp.com/auth/callback
`
SapixDB generates a CSRF state parameter and redirects to the provider's authorization page.
Step 2 — Handle the callback:
The provider redirects back to your redirect_uri with ?code=...&state=.... Your frontend POSTs the code to SapixDB:
curl -X POST http://localhost:7475/v1/auth/oauth/google/callback \
-H "Content-Type: application/json" \
-d '{ "code": "4/0AfGe...", "state": "abc123", "redirect_uri": "https://yourapp.com/auth/callback" }'Response: same as password login — access_token + refresh_token. If the email is new, a user account is created automatically.
Why ALLOWED_REDIRECT_URIS matters
OAuth redirect_uri validation is a critical security control. SapixDB rejects any callback with a redirect_uri not in the allowlist — this prevents open-redirect attacks that could steal authorization codes.
---
## Passkeys (WebAuthn / FIDO2)
Passkeys let users authenticate with a biometric sensor (Touch ID, Face ID) or hardware key (YubiKey). No password is transmitted.
Enable
SAPIX_WEBAUTHN_RP_ID=yourdomain.com # relying party ID (must match the browser origin)
SAPIX_WEBAUTHN_ORIGIN=https://yourdomain.com # exact origin, no trailing slashRegister a passkey
Registration is a two-step challenge/response flow:
Step 1 — Get a registration challenge:
`bash
curl -X POST http://localhost:7475/v1/auth/passkey/register-challenge \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{ "user_id": "usr_a3f9..." }'
`
Response:
`json
{ "challenge_id": "chal_...", "options": { /* PublicKeyCredentialCreationOptions */ } }
`
Pass options to navigator.credentials.create() in the browser.
Step 2 — Complete registration:
`typescript
const credential = await navigator.credentials.create({ publicKey: options });
await fetch('/v1/auth/passkey/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: Bearer ${accessToken} },
body: JSON.stringify({
challenge_id: challengeId,
credential: JSON.stringify(credential),
}),
});
`
Authenticate with a passkey
Step 1 — Get an authentication challenge:
`bash
curl -X POST http://localhost:7475/v1/auth/passkey/authenticate-challenge \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com" }'
`
Response:
`json
{ "challenge_id": "chal_...", "options": { /* PublicKeyCredentialRequestOptions */ } }
`
Step 2 — Complete authentication:
`typescript
const assertion = await navigator.credentials.get({ publicKey: options });
const res = await fetch('/v1/auth/passkey/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge_id: challengeId,
credential: JSON.stringify(assertion),
}),
});
// Response: { access_token, refresh_token, expires_in }
`
Passkey endpoint reference
| Method | Path | Auth required |
|---|---|---|
| POST | /v1/auth/passkey/register-challenge | JWT (logged-in user) |
| POST | /v1/auth/passkey/register | JWT (logged-in user) |
| POST | /v1/auth/passkey/authenticate-challenge | None |
| POST | /v1/auth/passkey/authenticate | None |
Challenge sessions have a 5-minute TTL and are single-use.
---
## Custom Claims — app_metadata & user_metadata
JWTs issued by SapixDB embed two metadata objects:
| Field | Who controls it | Visible in JWT |
|---|---|---|
app_metadata | Server-side only (admin API key required to write) | Yes |
user_metadata | User can write via PATCH /v1/auth/me | Yes |
Set app_metadata (server-side)
Requires an admin-scoped API key — your frontend cannot call this directly.
curl -X PATCH http://localhost:7475/v1/auth/users/usr_a3f9.../app-metadata \
-H "Authorization: Bearer spx_key_admin..." \
-H "Content-Type: application/json" \
-d '{ "role": "admin", "plan": "enterprise" }'Update user_metadata (self-service)
curl -X PATCH http://localhost:7475/v1/auth/me/user-metadata \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{ "display_name": "Alice", "theme": "dark" }'Reading claims in your backend
After the user refreshes their JWT (or logs in again), the new token includes the updated claims:
`typescript
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('http://localhost:7475/v1/auth/jwks'));
const { payload } = await jwtVerify(token, JWKS, { algorithms: ['EdDSA'] });
console.log(payload.app_metadata?.role); // 'admin'
console.log(payload.user_metadata?.theme); // 'dark'
`
> Important: app_metadata is for authorization decisions your server makes. user_metadata is for preferences the user controls. Never put sensitive billing or admin flags in user_metadata.
---
## Auth add-on: full endpoint summary
| Method | Path | Description |
|---|---|---|
| POST | /v1/auth/oauth/:provider/authorize | Begin OAuth flow (Google, GitHub) |
| POST | /v1/auth/oauth/:provider/callback | Exchange OAuth code for tokens |
| POST | /v1/auth/passkey/register-challenge | Start passkey registration |
| POST | /v1/auth/passkey/register | Complete passkey registration |
| POST | /v1/auth/passkey/authenticate-challenge | Start passkey login |
| POST | /v1/auth/passkey/authenticate | Complete passkey login → JWT |
| PATCH | /v1/auth/users/:id/app-metadata | Set app_metadata (admin key) |
| PATCH | /v1/auth/me/user-metadata | Update user_metadata (JWT) |
---
## Challenge
- Set
app_metadata.role = "admin"on your test user viaPATCH /v1/auth/users/:id/app-metadata(use your root API key). - Log in again with
POST /v1/auth/loginto get a fresh JWT. - Decode the JWT at [jwt.io](https://jwt.io) and confirm
app_metadata.roleis"admin"in the payload. - Bonus: update
user_metadata.display_nameviaPATCH /v1/auth/me/user-metadataand verify it appears inGET /v1/auth/me.
---
See also: Lesson 58 (register, login, magic links), Lesson 35 (IP rate limiting on auth routes), Lesson 57 (Alabay security watchdog).