Authentication & Authorization Model
Companion to auth-boundaries. The boundaries doc states the rule per service; this doc explains the mechanism: who issues tokens, who verifies them, where authorization decisions are made, and how services trust each other.
Identity providers
Two identity sources are in use:
| Source | Used for | Verified by |
|---|---|---|
| Clerk | All end-user identity (web + admin) | @clerk/backend, @clerk/nextjs, clerk-sdk-go/v2 |
| API key | Service-to-service into brain | apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts |
Clerk issues two token shapes that the verifiers handle differently:
- JWT session tokens — short-lived, verified locally with the secret key (no Clerk round-trip).
- OAuth opaque tokens prefixed
oat_— exchanged at${CLERK_FRONTEND_API_URL}/oauth/userinfofor the underlyingsub(seeapps/brain/src/modules/application/auth/strategies/clerk.strategy.ts:40-103).
Token verification per service
| Service | Edge verifier | Library | Notes |
|---|---|---|---|
| brisket | apps/brisket/src/middleware.ts | @clerk/nextjs/server clerkMiddleware | Public route allowlist; same-origin redirect validation |
| fennec | apps/fennec/src/lib/auth/clerk.ts | @clerk/clerk-react; SPA only — Clerk session token is fetched client-side and forwarded to brain (apps/fennec/src/lib/backend/apiClient.ts callers). Token verification happens in brain via @clerk/backend, not in fennec | Admin SPA |
| strip | apps/strip/internal/app/middleware/auth.go | clerk-sdk-go/v2 via services.ClerkService | Custom Fiber middleware; 10s Clerk timeout, 8KB token cap |
| brain | apps/brain/src/modules/application/auth/guards/clerk-auth.guard.ts | @clerk/backend | NestJS Passport guard; @Public() and API-key escape hatches |
| flank | apps/flank/app/lib/auth.ts | @clerk/backend authenticateRequest | Server-function-level requireAuth() |
| sirloin | apps/sirloin/internal/pkg/clerk/ | clerk-sdk-go/v2 | Used for user lookups, not token validation at the gRPC edge — see Service-to-service auth |
| round | none | — | Internal-only; no auth boundary today |
Verification flow
flowchart LR Browser[User browser] -->|Clerk session JWT or oat_*| Brisket Browser -->|Clerk session| Fennec Browser -->|Clerk session| Flank AdminBrowser[Admin browser] -->|Clerk session JWT| Strip
Brisket -->|REST + user context| Sirloin Strip -->|gRPC + user_id in request| Sirloin Flank -->|gRPC + user_id| Sirloin
Sirloin -->|gRPC| Brain Sirloin -->|API key header| Brain Sirloin -->|gRPC| Round Brain -->|gRPC| Round
Sirloin -->|user lookups via clerk-sdk-go| Clerk[(Clerk)]Authorization decisions
Authorization is not centralized. Each service makes its own decisions:
- Strip RBAC:
apps/strip/internal/app/authorization/authorization.godefinesPermissionconstants and 5 roles —root,admin,creator,support,viewer. Roles are persisted by sirloin (modelapps/sirloin/internal/pkg/models/stripadminrole.go) and cached in strip for 5 minutes (apps/strip/internal/app/services/authorization.go). Default for unknown users isviewer. - Sirloin per-RPC checks: rate limits and ownership checks live in
service code. The rate limiter
(
apps/sirloin/internal/pkg/ratelimit/ratelimit.go) readsGetUserId()/GetUserEmail()off the request proto and bypasses internal@foxy.aiemails (see Bypass patterns). - Brain route metadata:
@Public()and@ApiKeyRoute()decorators flip the Clerk guard off per-route (apps/brain/src/modules/application/auth/guards/clerk-auth.guard.ts:21-43). When a route is API-key-only theApiKeyAuthGuardruns instead. - Flank:
requireAuth()is the only check —apps/flank/app/lib/auth.ts:17. There is no per-workflow ACL today. FlankMCP role gating lives in sirloin: every tool entry intoolMinimumRolerequiresadmin(apps/sirloin/internal/app/flankmcp/roles.go:30-55), enforced viaisToolAllowedForRole(...)at the tool dispatch site (apps/sirloin/internal/app/flankmcp/server.go:295-300). Roles are resolved from the sameStripAdminRolesource as Strip RBAC.
Service-to-service auth
| Caller → Callee | Mechanism | Code reference |
|---|---|---|
| sirloin → brain (HTTP) | SIRLOIN_BRAIN_API_KEY shared secret | .env.example line SIRLOIN_BRAIN_API_KEY=secretkey1; brain side apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts |
| sirloin → round (gRPC) | No transport security — grpc.WithTransportCredentials(insecure.NewCredentials()) | apps/sirloin/cmd/app/main.go:237, :263 |
| sirloin → flank (gRPC, FlankExecutionService) | Connect-es over connectNodeAdapter (apps/flank/server/grpc-server.ts:8-20); no auth interceptor is registered on the server router — relies on internal-network trust | apps/flank/server/grpc-server.ts |
| flank → sirloin (gRPC, FlankStorageService) | Connect-es createGrpcTransport to SIRLOIN_GRPC_URL (apps/flank/app/lib/grpc-client.ts:6-29); the only interceptor re-throws errors as plain Error — no auth header / token attached | apps/flank/app/lib/grpc-client.ts |
| brain → round (gRPC) | credentials.createInsecure() | apps/brain/src/modules/application/round/providers/round-grpc-client.provider.ts:19 |
| sirloin gRPC server | Plain TCP, no TLS configured at server | apps/sirloin/cmd/app/main.go:552 grpc.NewServer(...) — no TLS option |
Implication: All inter-service gRPC traffic relies on the network being internal (Docker network in dev, Railway private network in prod — see Security model). There is no mTLS today.
API key scopes
Brain’s API-key strategy validates membership in a flat list
(AUTHORIZED_KEYS env var, comma-separated). There are no scopes
or per-key permissions; possession of any listed key authorizes any
@ApiKeyRoute() endpoint
(apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts:12-49).
The key comparison uses Array.includes(...) rather than a
constant-time compare (apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts:41).
TODO(@pawel): assess timing-attack risk given keys are long random
strings, and switch to crypto.timingSafeEqual if a mitigation is
warranted.
Bypass patterns (real, found in code)
These bypasses exist and must be tracked because they alter trust:
FLANK_AUTH_BYPASS_UUID—apps/flank/app/lib/auth.ts:4,17-20. When set,requireAuth()returns the UUID immediately without contacting Clerk. Documented as local-dev-only but is plain env-driven.- Strip dev mode —
apps/strip/internal/app/middleware/auth.go:79-87readscfg.Stage == env.StageDevelopmentintodevMode. Two distinct bypasses exist:STRIP_AUTH_BYPASSUUID (env-drivencfg.AuthBypassUUID), accepted viaX-Auth-Bypassheader or?auth=query param atauth.go:92-112regardless of stage; setsuserId=test_user,userEmail=test@example.comand logsSECURITY WARNING.- Dev-no-Clerk fallthrough at
auth.go:117-125: whenam.devMode && am.clerkService == nilthe request is admitted asuserId=dev_user. This branch should never trigger in staging or production.
- Internal email bypass on rate limits —
apps/sirloin/internal/pkg/ratelimit/ratelimit.go: any caller whose request proto returns an@foxy.aiemail skips the limiter (fraud.IsInternalEmail). This is rate-limit only, not authn/authz. - Brain
@Public()decorator — bypasses Clerk for the route. The only@Public()use sites in code areapps/brain/src/app.controller.ts:10(GET /hello) and:16(GET /health). No other public routes were found via grep acrossapps/brain/src. - OPTIONS preflight — brain’s Clerk guard returns
truefor anyOPTIONSrequest (apps/brain/src/modules/application/auth/guards/clerk-auth.guard.ts:17-19).
Auth boundaries diagram
flowchart TB subgraph Internet UB[User browser] AB[Admin browser] CB[Chargebee] PR[Primer] end
subgraph Trusted internal network BR[brisket Next.js] FE[fennec admin] FL[flank] ST[strip] SI[sirloin gRPC + REST] BN[brain] RD[round] end
UB -->|Clerk JWT| BR UB -->|Clerk JWT| FE UB -->|Clerk JWT| FL AB -->|Clerk JWT| ST CB -->|Basic auth: chargebee_username/pass| SI PR -->|HMAC X-Signature-Primary| SI
BR -->|REST, user context| SI ST -->|gRPC, user_id in proto| SI FL -->|gRPC| SI SI -->|API key| BN SI -->|gRPC insecure| RD BN -->|gRPC insecure| RDCross-references
- Auth boundaries — short rule statement per service
- Authentication flow — sequence diagrams
- Security model — secrets, transport, webhook signatures
- ADRs touching auth: none under
docs/src/content/docs/decisions/as of 2026-05-05.
Open TODOs
- TODO(@pawel): timing-attack analysis for brain
AUTHORIZED_KEYScomparison and decision oncrypto.timingSafeEqual. - TODO(@law): plan to introduce mTLS or any transport encryption on the internal gRPC mesh — none found in code today.