← All lessons/Add-ons
59
Add-ons

OAuth, Passkeys & Custom Claims

Social login with Google or GitHub, hardware-backed passkeys via WebAuthn, and embedding roles or metadata in JWTs.

Prerequisite: Lesson 58 complete

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
Challenge

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 slash

Register 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

MethodPathAuth required
POST/v1/auth/passkey/register-challengeJWT (logged-in user)
POST/v1/auth/passkey/registerJWT (logged-in user)
POST/v1/auth/passkey/authenticate-challengeNone
POST/v1/auth/passkey/authenticateNone

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:

FieldWho controls itVisible in JWT
app_metadataServer-side only (admin API key required to write)Yes
user_metadataUser can write via PATCH /v1/auth/meYes

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

MethodPathDescription
POST/v1/auth/oauth/:provider/authorizeBegin OAuth flow (Google, GitHub)
POST/v1/auth/oauth/:provider/callbackExchange OAuth code for tokens
POST/v1/auth/passkey/register-challengeStart passkey registration
POST/v1/auth/passkey/registerComplete passkey registration
POST/v1/auth/passkey/authenticate-challengeStart passkey login
POST/v1/auth/passkey/authenticateComplete passkey login → JWT
PATCH/v1/auth/users/:id/app-metadataSet app_metadata (admin key)
PATCH/v1/auth/me/user-metadataUpdate user_metadata (JWT)

---

## Challenge

  1. Set app_metadata.role = "admin" on your test user via PATCH /v1/auth/users/:id/app-metadata (use your root API key).
  2. Log in again with POST /v1/auth/login to get a fresh JWT.
  3. Decode the JWT at [jwt.io](https://jwt.io) and confirm app_metadata.role is "admin" in the payload.
  4. Bonus: update user_metadata.display_name via PATCH /v1/auth/me/user-metadata and verify it appears in GET /v1/auth/me.

---

See also: Lesson 58 (register, login, magic links), Lesson 35 (IP rate limiting on auth routes), Lesson 57 (Alabay security watchdog).

← Previous
Lesson 58: User Auth — Register, Login & Magic Links