Skip to content

Clerk Integration Playbook

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_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.

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.

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 Customer-app user lookups (user.Get); content-team user management (create/delete) in the Strip/Fennec admin org via a dedicated key clerk-sdk-go/v2 apps/sirloin/internal/pkg/clerk/{usermanager,useremail}.go
round None — internal-only, no Clerk boundary — —

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_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.

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)

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.

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.

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 concurrent first calls RequestContextInterceptor synchronizes the Clerk user on every authenticated request (apps/brain/src/common/context/interceptors/request-context.interceptor.ts:52-64). UserRepository.upsertByClerkId uses one Prisma upsert keyed by the unique clerkId (apps/brain/src/modules/domain/user/repositories/user.repository.ts:45-58; apps/brain/prisma/schema.prisma:208-216), so parallel first requests converge on one row. Existing rows refresh email without overwriting Brain-owned names.
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 has explicit development bypass behavior; Flank has no current environment-driven bypass. Audit Strip bypass configuration in non-dev deploys and require Clerk for Flank everywhere.
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
SIRLOIN_STRIP_CLERK_SECRET_KEY sirloin - dedicated Clerk secret for the Strip/Fennec admin org, used only for content-team user management (StripCreateClerkUser/StripDeleteClerkUser → apps/sirloin/internal/pkg/clerk/usermanager.go) .env.example; must point at a different org than SIRLOIN_CLERK_API_KEY. Optional: when unset the create/delete RPCs fail closed
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.

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.

  • 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.

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