Skip to content

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/userinfo for the underlying sub (apps/brain/src/modules/application/auth/strategies/clerk.strategy.ts).

Brain owns the canonical user record (users table, keyed by clerk_idapps/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" --> Brisket

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

ServiceUsageLibraryCode path
brisketNext.js middleware (UI session + JWT verify)@clerk/nextjs@^6.35apps/brisket/src/middleware.ts
brisketWebhook receiver (/api/webhook/clerk)svixapps/brisket/src/app/api/webhook/clerk/route.ts
fennecBrowser SDK (admin SPA session)@clerk/react@^6.1apps/fennec/src/App.tsx, apps/fennec/src/lib/auth/clerk.ts
stripFiber middleware (admin JWT verify)clerk-sdk-go/v2 via services.ClerkServiceapps/strip/internal/app/middleware/auth.go
brainNestJS Passport guard (REST JWT verify)@clerk/backend@^1.25apps/brain/src/modules/application/auth/{guards,strategies}/clerk*
flankServer-function requireAuth()@clerk/backend@^3.2 (authenticateRequest)apps/flank/app/lib/auth.ts
sirloinUser lookups only — user.Get, user.Createclerk-sdk-go/v2apps/sirloin/internal/pkg/clerk/{createuser,useremail}.go
roundNone — 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 verificationsvix.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_IN events 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 delayed user.created webhook does not block sign-in.
  • Public route: /api/webhook/clerk(.*) is in isPublicRoute matcher 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

ConcernBehaviour
Token formatheader.payload.signature (3 parts, 2 dots)
Strip pre-checkslen(token) <= 8 KiB, exactly 2 dots (apps/strip/internal/app/middleware/auth.go)
Local verificationverifyToken(token, { secretKey: CLERK_SECRET_KEY }) — uses Clerk’s networkless verification (apps/brain/.../clerk.strategy.ts:40-44)
JWKS cacheHandled internally by @clerk/backend and clerk-sdk-go/v2. TODO(@law): cache TTL and refresh strategy per SDK (not configured in this repo).
Audience claimNo explicit audience config in repo; relies on SDK defaults. TODO(@law): pin audience if Clerk JWT templates are introduced.
Clock skew toleranceRelies on SDK defaults; no code-level override. TODO(@law): document SDK default.
Strip Clerk-call timeout10 s (clerkVerifyTimeout, apps/strip/internal/app/middleware/auth.go)
Brain oat_* exchange timeout5 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):

  1. OPTIONS preflight → allow.
  2. @Public() decorator (IS_PUBLIC_KEY) → allow without auth.
  3. @ApiKeyRoute() decorator (IS_API_KEY_ROUTE) → defer to API-key strategy instead of Clerk.
  4. 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

EdgeTokenVerifier
Browser → brisket / strip / fennec / flankClerk session JWTper-service Clerk SDK
Brisket → sirloinClerk session JWT in REST bearersirloin 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 → sirloingRPC + user_id in proto requesttrusts upstream
Sirloin → brainAPI key header and/or end-user JWT bearerbrain ApiKeyStrategy or ClerkStrategy (@ApiKeyRoute() chooses)
Sirloin → roundgRPC, insecurenone (internal)
Brain → roundgRPC, insecurenone (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

FailureDetectionMitigation
JWKS / Clerk API unreachableStrip: 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 expiredStrip: 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 rotationAll 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 / replaybrisket 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 mismatchsvix.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 callRequestContextInterceptor 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 unsetBrain 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 stagingStrip 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

VariableWhere usedSource
*_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_SECRETbrisket — svix Webhook ctor.env.example:150; apps/brisket/.env.example:4
BRISKET_CLERK_ENCRYPTION_KEYbrisket — Clerk org/session encryption.env.example:149
STRIP_CLERK_DOMAINstrip — Clerk frontend API hostname.env.example:130
STRIP_IMPERSONATION_CLERK_SECRET_KEYstrip - main app Clerk backend secret for customer actor-token impersonation.env.example:133; must not be the Strip admin Clerk secret
CLERK_FRONTEND_API_URLbrain — /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_KEYsirloin 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_KEY to the browser; only the publishable key is shipped to clients.
  • Strip has a separate STRIP_CLERK_DOMAIN knob — verify it matches the Clerk instance the secret key belongs to, or token verification fails.
  • Brain’s @clerk/backend SDK 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

TODO(@law): add cross-links to per-service runbooks for brisket, strip, fennec, flank once Clerk-specific runbook entries land.