Clerk Integration Playbook
1. Overview
Clerk is the identity provider for human users (web visitors and admin operators) across beef. Every user-facing service verifies a Clerk-issued credential at its edge. Service-to-service hops do not carry Clerk tokens — they use API keys (see Auth Model).
Two token shapes are in use:
- Session JWTs — short-lived, verified locally with
CLERK_SECRET_KEY(no Clerk round-trip). - OAuth opaque tokens prefixed
oat_— exchanged at${CLERK_FRONTEND_API_URL}/oauth/userinfofor the underlyingsub(apps/brain/src/modules/application/auth/strategies/clerk.strategy.ts).
Brain owns the canonical user record (users table, keyed by clerk_id — apps/brain/prisma/schema.prisma:204-218). Sirloin only calls Clerk for user lookups by ID, not edge token validation.
This page extends, but does not duplicate, Auth Model and Auth Boundaries.
2. Architecture
flowchart LR Browser[User browser] AdminBrowser[Admin browser] Clerk[(Clerk)] Brisket[brisket Next.js] Strip[strip Fiber] Brain[brain NestJS] Sirloin[sirloin Go] Flank[flank] Fennec[fennec admin SPA]
Browser -- "sign-in / sign-up" --> Clerk AdminBrowser -- "sign-in" --> Clerk
Browser -- "Clerk session JWT" --> Brisket Browser -- "Clerk session JWT" --> Flank Browser -- "Clerk session JWT" --> Fennec AdminBrowser -- "Clerk session JWT" --> Strip
Brisket -- "REST + bearer JWT" --> Sirloin Sirloin -- "REST + bearer JWT (per-token)" --> Brain Brain -- "verifyToken (local) or oat_/userinfo" --> Clerk Sirloin -- "user.Get(clerkId)" --> Clerk
Clerk -- "user.created / session.created (svix)" --> Brisket Brisket -- "PostHog / Klaviyo events" --> BrisketUser-row creation is lazy, not webhook-driven. The first authenticated brain request triggers RequestContextInterceptor to upsert the User row keyed by clerkId (apps/brain/src/common/context/interceptors/request-context.interceptor.ts:48-60). The brisket user.created webhook is observability-only today (PostHog signup event); it does not provision brain rows.
Sirloin does not cache a brain User.id ↔ clerkId mapping. The foxy360 surface
caches the brain role keyed by the bearer token (brainRoleCache with
default + failure TTLs in apps/sirloin/internal/app/foxy360/brainresttools.go:222-272)
and otherwise issues a fresh GET /users/me per call. There is no in-process
clerk-id index in sirloin.
3. Per-service usage
| Service | Usage | Library | Code path |
|---|---|---|---|
| brisket | Next.js middleware (UI session + JWT verify) | @clerk/nextjs@^6.35 | apps/brisket/src/middleware.ts |
| brisket | Webhook receiver (/api/webhook/clerk) | svix | apps/brisket/src/app/api/webhook/clerk/route.ts |
| fennec | Browser SDK (admin SPA session) | @clerk/react@^6.1 | apps/fennec/src/App.tsx, apps/fennec/src/lib/auth/clerk.ts |
| strip | Fiber middleware (admin JWT verify) | clerk-sdk-go/v2 via services.ClerkService | apps/strip/internal/app/middleware/auth.go |
| brain | NestJS Passport guard (REST JWT verify) | @clerk/backend@^1.25 | apps/brain/src/modules/application/auth/{guards,strategies}/clerk* |
| flank | Server-function requireAuth() | @clerk/backend@^3.2 (authenticateRequest) | apps/flank/app/lib/auth.ts |
| sirloin | User lookups only — user.Get, user.Create | clerk-sdk-go/v2 | apps/sirloin/internal/pkg/clerk/{createuser,useremail}.go |
| round | None — internal-only, no Clerk boundary | — | — |
4. Webhooks received
Sole receiver: brisket at POST /api/webhook/clerk (apps/brisket/src/app/api/webhook/clerk/route.ts).
- Signature verification —
svix.Webhook(env.CLERK_WEBHOOK_SECRET).verify(body, headers). Headers required:svix-id,svix-timestamp,svix-signature. On verification failure: HTTP 400. - Event types handled:
user.created,session.created. Unknown types are accepted (200) but no-op. - Side effects: PostHog
ACCOUNT_CREATED/LOGGED_INevents with extracted SSO method (oauth_google → google, etc.). No database writes, no fan-out to brain. - Idempotency: none — replays would re-fire PostHog events. Safe today because the events are analytics-only.
- Ordering risk: low. Brain user provisioning is decoupled from this webhook (lazy upsert in
RequestContextInterceptor); a delayeduser.createdwebhook does not block sign-in. - Public route:
/api/webhook/clerk(.*)is inisPublicRoutematcher in middleware (apps/brisket/src/middleware.ts:5-14).
Confirmed: only brisket registers a Clerk webhook endpoint. A repo-wide grep
for svix / CLERK_WEBHOOK_SECRET returned the single
apps/brisket/src/app/api/webhook/clerk/route.ts receiver — brain, sirloin,
flank, and strip do not consume Clerk webhooks.
5. JWT verification
| Concern | Behaviour |
|---|---|
| Token format | header.payload.signature (3 parts, 2 dots) |
| Strip pre-checks | len(token) <= 8 KiB, exactly 2 dots (apps/strip/internal/app/middleware/auth.go) |
| Local verification | verifyToken(token, { secretKey: CLERK_SECRET_KEY }) — uses Clerk’s networkless verification (apps/brain/.../clerk.strategy.ts:40-44) |
| JWKS cache | Handled internally by @clerk/backend and clerk-sdk-go/v2. TODO(@law): cache TTL and refresh strategy per SDK (not configured in this repo). |
| Audience claim | No explicit audience config in repo; relies on SDK defaults. TODO(@law): pin audience if Clerk JWT templates are introduced. |
| Clock skew tolerance | Relies on SDK defaults; no code-level override. TODO(@law): document SDK default. |
| Strip Clerk-call timeout | 10 s (clerkVerifyTimeout, apps/strip/internal/app/middleware/auth.go) |
Brain oat_* exchange timeout | 5 s, cached 15 min × 4 096 entries (USERINFO_TTL_MS, USERINFO_MAX_ENTRIES in clerk.strategy.ts) |
6. Public vs protected routes
Brisket (Next.js middleware) — explicit public allowlist via createRouteMatcher; everything else calls auth.protect() (apps/brisket/src/middleware.ts):
/sign-in(.*), /sign-up(.*), /waitlist(.*),/api/health(.*), /api/webhook/clerk(.*),/forgot-password(.*), /verification, /.well-known(.*)Authenticated users hitting /sign-in or /sign-up are redirected to /explore, with a same-origin validator on redirect_url to block open redirects (validateRedirectUrl).
Brain (NestJS guard) — ClerkAuthGuard extends AuthGuard('clerk') with three escape hatches in order (apps/brain/.../guards/clerk-auth.guard.ts):
OPTIONSpreflight → allow.@Public()decorator (IS_PUBLIC_KEY) → allow without auth.@ApiKeyRoute()decorator (IS_API_KEY_ROUTE) → defer to API-key strategy instead of Clerk.- Otherwise → run Clerk strategy.
As of this commit, the only @Public() use sites in brain are
AppController.getHello (GET /) and AppController.health (GET /health)
in apps/brain/src/app.controller.ts:10,16. All other routes fall through to
ClerkAuthGuard. Re-grep @Public() after schema changes; central registry
is not maintained.
Strip — middleware applies to all non-public routes; dev mode bypass (devMode from cfg.Stage == StageDevelopment) writes a fake dev_fallback_user. In staging/prod a missing clerkService returns 503.
7. API keys vs Clerk JWTs
| Edge | Token | Verifier |
|---|---|---|
| Browser → brisket / strip / fennec / flank | Clerk session JWT | per-service Clerk SDK |
| Brisket → sirloin | Clerk session JWT in REST bearer | sirloin forwards the bearer token to brain (apps/sirloin/internal/app/foxy360/brainresttools.go:115); no Clerk JWT verification happens in sirloin itself — brain’s ClerkAuthGuard is the verifier |
| Strip → sirloin | gRPC + user_id in proto request | trusts upstream |
| Sirloin → brain | API key header and/or end-user JWT bearer | brain ApiKeyStrategy or ClerkStrategy (@ApiKeyRoute() chooses) |
| Sirloin → round | gRPC, insecure | none (internal) |
| Brain → round | gRPC, insecure | none (internal) |
Service-to-service identity into brain uses an API key, not a Clerk JWT. Routes opt in via @ApiKeyRoute(). Webhooks (e.g. /api/webhook/rp/training, /api/webhook/vi/created) are API-key authed, not Clerk-authed (apps/brain/.../webhook.http.controller.ts). See Auth Model — Identity providers.
8. Failure modes
| Failure | Detection | Mitigation |
|---|---|---|
| JWKS / Clerk API unreachable | Strip: 10 s context timeout fires; logs Clerk verification timed out; AJAX → 503, page → redirect to /login. Brain: SDK error surfaces as UnauthorizedException. | Per-service Clerk timeout (10 s strip, 5 s brain oat_*). No retries on the hot path. Clients re-attempt on next request. |
| Token expired | Strip: services.IsTokenExpiredError → 401 token_expired for AJAX, redirect for full pages. Brain: verifyToken throws → 401. | Browser SDKs auto-refresh; AJAX clients should re-fetch a session token and retry. |
| Signing key rotation | All verifiers fetch JWKS via SDK; stale-cache risk during rotation window. | Rely on SDK auto-rotation. TODO(@law): explicit rotation runbook. |
| Webhook out-of-order / replay | brisket webhook is analytics-only — replays double-count signup events. | TODO: dedupe by svix-id if PostHog accuracy becomes critical. Brain user provisioning is independent of the webhook (lazy upsert), so ordering does not cause auth failures. |
| Webhook signature mismatch | svix.verify throws → 400 Invalid signature; logged with headers. | Operator must ensure CLERK_WEBHOOK_SECRET matches the Clerk dashboard endpoint secret. |
Deleted Clerk user with stale oat_* | Brain userinfo cache holds the user up to USERINFO_TTL_MS (15 min) after deletion. | Accept ≤ 15 min revocation lag. Reduce USERINFO_TTL_MS if revocation must be tighter. |
| Brain user row missing on first call | RequestContextInterceptor upserts on every authenticated request — first call creates the row. Race: two concurrent requests for a brand-new Clerk user can both call create. | User.clerkId carries @unique @map("clerk_id") in apps/brain/prisma/schema.prisma:206; the second concurrent insert raises a Prisma P2002. createOrUpdateUser (apps/brain/src/modules/domain/user/services/user.service.ts:47-67) does a get-then-create without a transaction, so the loser’s request currently 500s and the client must retry. TODO(@law): wrap in upsert to make the race idempotent. |
oat_* token but CLERK_FRONTEND_API_URL unset | Brain logs Received oat_* token but CLERK_FRONTEND_API_URL is not configured and 401s. | Set CLERK_FRONTEND_API_URL per environment. |
| Dev-mode bypass leaks to staging | Strip refuses to fall back when cfg.Stage != StageDevelopment. Flank FLANK_AUTH_BYPASS_UUID has no such guard. | Audit env in non-dev deploys; never set FLANK_AUTH_BYPASS_UUID outside local. |
9. Secrets
| Variable | Where used | Source |
|---|---|---|
*_CLERK_PUBLISHABLE_KEY (pk_live_… / pk_test_…) | brisket, strip, fennec, flank, brain | .env.example lines 129, 147, 222; apps/brisket/.env.example; apps/flank/.env.example; apps/brain/.env.example |
*_CLERK_SECRET_KEY (sk_live_… / sk_test_…) | brisket, strip, brain, flank, sirloin (SIRLOIN_CLERK_API_KEY) | .env.example:81,131,148,222; per-app .env.example |
BRISKET_CLERK_WEBHOOK_SECRET | brisket — svix Webhook ctor | .env.example:150; apps/brisket/.env.example:4 |
BRISKET_CLERK_ENCRYPTION_KEY | brisket — Clerk org/session encryption | .env.example:149 |
STRIP_CLERK_DOMAIN | strip — Clerk frontend API hostname | .env.example:130 |
STRIP_IMPERSONATION_CLERK_SECRET_KEY | strip - main app Clerk backend secret for customer actor-token impersonation | .env.example:133; must not be the Strip admin Clerk secret |
CLERK_FRONTEND_API_URL | brain — /oauth/userinfo exchange for oat_* (apps/brain/src/modules/application/auth/strategies/clerk.strategy.ts:72) | Read via configService; not currently declared in apps/brain/.env.example. TODO(@law): add to .env.example for parity. |
SIRLOIN_FOXY360_CLERK_API_KEY | sirloin foxy360 surface | .env.example:30 |
Storage: secrets are referenced as plain env vars per service. TODO(@law): the secret backend (Doppler / 1Password / Vault / Railway) is not documented in this repo.
Rotation: rotate via the Clerk dashboard, then update each environment. Brain and sirloin call sites read the secret at process start (createClerkClient, SDK init) — services must restart to pick up rotated secrets. The brisket webhook secret is independent and rotated separately at the Clerk endpoint level.
10. Sandbox vs prod
Clerk is selected by which (publishable, secret) pair is set per environment. pk_test_… / sk_test_… point at the development Clerk instance; pk_live_… / sk_live_… at production. There is no in-app feature flag — instance switching is purely env-driven.
Notes:
- Brisket exposes
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYto the browser; only the publishable key is shipped to clients. - Strip has a separate
STRIP_CLERK_DOMAINknob — verify it matches the Clerk instance the secret key belongs to, or token verification fails. - Brain’s
@clerk/backendSDK derives the API root from the secret key automatically; no domain override is needed.
TODO(@law): whether dev and prod share the same CLERK_WEBHOOK_SECRET or whether each Clerk instance has its own webhook endpoint.
11. Dashboards
- Clerk dashboard:
https://dashboard.clerk.com/— instance selector top-left. - Per-instance areas used:
- Users — manual provisioning, role overrides, banning.
- Webhooks — endpoint URL (
https://<brisket-domain>/api/webhook/clerk), signing secret, last-delivery log. - API Keys — publishable / secret rotation.
- JWT Templates — TODO(@law): are any custom templates configured?
- Sessions — for debugging session issues reported via strip / brisket logs.
TODO(@law): canonical Clerk instance IDs for dev and prod, and which team accounts have admin access.
12. Runbook hooks
- Brain runbook — restart procedure when Clerk SDK init fails at boot.
- Brain on-call — auth-failure spikes and
RequestContextInterceptorerrors. - Auth Model — canonical token taxonomy.
- Auth Boundaries — per-service edge rules.
- Security Model — webhook signing, transport, secret handling.
TODO(@law): add cross-links to per-service runbooks for brisket, strip, fennec, flank once Clerk-specific runbook entries land.