Skip to main content

Authentication architecture

Scope: how identity works end to end — backend guard stack and claims, webhook org-mirroring, the two fixed-purpose API keys, and the portal's session-token plumbing. Not the team/invite endpoints the guards protect — Team & organization management.

OptoLink has no in-house login. Identity is owned by Clerk; the backend only verifies Clerk session tokens, enforces a role ladder carried inside those tokens, and mirrors users/orgs/memberships into PostgreSQL via Clerk webhooks. This page traces the full backend path: request → guards → org resolution → service → Prisma, and back.

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (B-012 re-verification). Live-behavior claims are dated records from the 2026-09-17 deployed-stack re-audit, not code reads.

The three auth mechanisms (disjoint surfaces)​

MechanismGuardSurfaceCredential
Clerk session (humans)ClerkAuthGuard/portal/* + the ops-only /dev sandbox (src/sandbox/sandbox.controller.ts)Clerk session JWT (Authorization: Bearer)
API key (machines/SDK)ApiKeyAuthGuardSDK + link routes (src/link/link.controller.ts, src/app-event/sdk-events.controller.ts, src/identity/sdk.controller.ts)opl_sdk_* (CLIENT) / opl_api_* (SERVER) — see Fixed-purpose API keys
Admin bearer (legacy ops)AdminAuthGuard/admin/organizations (CRUD, suspend/activate) and /admin/organizations/:orgId/domains (domain moderation) (src/organization/organization.controller.ts, src/domain/domain.controller.ts)ADMIN_BEARER_TOKEN env, timing-safe compare (src/auth/guards/admin-auth.guard.ts)

API keys never authenticate /portal/* endpoints. ApiKeyAuthGuard is only wired on the SDK-facing controllers; every portal controller starts with ClerkAuthGuard. The Clerk webhook receiver (src/clerk/clerk-webhook.controller.ts) has no auth guard; it is authenticated by svix signature instead.

Request path (portal)​

portal SPA (api-client) ClerkAuthGuard RolesGuard RateLimitGuard
getToken() → Bearer <session JWT> ──HTTP──▶ verify JWT via JWKS ──▶ @Roles check ──▶ Redis counter ──▶ handler
│
@OrgFromUser(OrgResolutionPipe): Clerk IDs → OrgMember row → Organization
  1. Client mints the token. The portal's api-client calls a Clerk-provided token provider (optolink-portal/src/lib/api-client.ts, setTokenProvider) and sends Authorization: Bearer <clerk-session-token> on every /portal/* request. Session tokens are short-lived JWTs (≈60 s, Clerk default) minted and cached client-side by the Clerk SDK and silently re-minted on expiry. The backend never mints tokens; it only verifies them.
  2. ClerkAuthGuard (src/auth/guards/clerk-auth.guard.ts) verifies the JWT against Clerk's JWKS via @clerk/backend's verifyToken({ secretKey: CLERK_SECRET_KEY }) and attaches a ClerkAuthContext to request.auth:
    • clerkUserId ← sub
    • clerkOrgId ← o.id (compact session-token claim) falling back to org_id
    • orgRole ← org_metadata.role (the application role, see Roles)
    • email ← email claim (optional)
  3. RolesGuard (src/auth/roles.guard.ts) reads @Roles(minRole) metadata (src/auth/roles.decorator.ts) and compares it against request.auth.orgRole.
  4. RateLimitGuard (src/rate-limiting/rate-limit.guard.ts) consumes a Redis token-bucket (rate-limiter-flexible) for handlers decorated with @RateLimit(...) (e.g. RateLimitPresets.portal). Key = API key ID when present, else client IP (proxy-aware via trust proxy, src/main.ts). Undecorated routes are unlimited; RATE_LIMIT_DISABLED=true is a test-only escape hatch.
  5. Org resolution. Portal handlers take @OrgFromUser(OrgResolutionPipe) org: Organization (src/portal/decorators/org-from-user.decorator.ts): maps Clerk user/org IDs to local rows and confirms membership through the OrgMember join table before the handler runs.
  6. Handler → service → Prisma, as usual.

The trio order ClerkAuthGuard → RolesGuard → RateLimitGuard (admins: ClerkAuthGuard → OpsRoleGuard → RateLimitGuard) is repeated explicitly on every portal controller via @UseGuards(...); there is no global guard registration. Examples: src/portal/portal-link.controller.ts, src/portal/portal-org.controller.ts, src/portal/portal-api-key.controller.ts, src/team/team.controller.ts, src/portal/portal-admin-organizations.controller.ts. Read-only controllers that need no role (e.g. PortalDashboardController) run just ClerkAuthGuard, RateLimitGuard.

Path routing. ClerkProvider wires routerPush/routerReplace + signInUrl/signUpUrl; <SignIn routing="path" path="/login"> / <SignUp routing="path" path="/register"> render inside splat routes (/login/*, /register/* in src/app/routes.tsx) so Clerk step URLs are real paths (/login/factor-one). Without the router integration Clerk falls back to #/factor-one hash fragments.

Token-mapped appearance. src/lib/clerk-appearance.ts maps every Clerk variable to portal CSS custom properties: zero hardcoded hexes, cards follow the active theme (dark default). colorMuted is pinned to var(--card) so the footer strip reads as one surface; cardBox gets marginInline: auto because Clerk's card doesn't self-center; controls pinned to the 6px tier, card 10px. Both auth pages share AuthShell (wordmark + centered column, src/components/brand/auth-shell.tsx).

Guard split. src/lib/route-guards.tsx: RequireAuth reads only Clerk's session hook; AuthRedirect is a session-only outer whose org-hook half lives in an inner AuthRedirectTarget that mounts only after session restore, so useOrganization never runs unauthenticated (the FB-1 fix). The app-side RequireOrg guard gates routes on org membership and the Phase 2.5 onboarding gate (needsOnboarding). Loading renders SplashScreen (branded), not bare text.

Failed guard reads render an error screen, not a redirect (FB-2). When the onboarding-state read fails in RequireOrg or AuthRedirectTarget, the portal renders a full-screen GuardErrorScreen (shared QueryErrorBanner + Retry via refetch) instead of redirecting: two failed gates must not bounce into each other. Landed 2026-09-03 in the FLOW-002 fix pass.

Return-URL safety. safeRedirectPath() (src/lib/utils.ts): same-origin relative paths only. Rejects //host, /\host (open-redirect vectors) and /login and /register targets (loop guard). RequireAuth bounces with ?redirect=<pathname+search>; restore precedence after sign-in: onboarding gate → validated ?redirect= → ADMIN default (when not impersonating) → dashboard. Accept/reject table: utils.test.ts.

Search preservation. Both auth pages capture location.search once at mount (useState initializer) and replay it via fallbackRedirectUrl, because Clerk's internal step navigations drop unknown query params.

Auth pages are pure Clerk. Zero /portal/* calls from /login / /register (re-verified deployed, network trace 2026-09-17); first portal-API traffic fires only after the post-auth redirect split (onboarding-state, dashboard queries).

Per-instance config drift (LIVE-1 class). The card heading is the Clerk instance display name ("Sign in to <name>"); app code sets no Clerk text. Local (evident-terrapin-22) and deployed (active-shrimp-6400) are separate instances; dashboard one-offs must be repeated per environment (same class as the FLOW-010 price-ID gotcha).

Fixed-purpose API keys​

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (B-012 re-verification; section originated as the FLOW-010 rider).

Exactly two credential tiers per org, structurally enforced — there is no key list and no create endpoint. The old api_keys quota was removed from plans/entitlements; access is the api_access feature flag (src/entitlements/plans.config.ts: false for STARTER/SOLO, true for GROWTH/SCALE/ENTERPRISE). The two-key shape comes from the data model, not a quota check.

TierPrefixConsumers
CLIENTopl_sdk_/sdk/session, /sdk/identity, /sdk/identity/clear (src/app-event/sdk-events.controller.ts, src/identity/sdk.controller.ts)
SERVERopl_api_link CRUD (src/link/link.controller.ts) — the Node SDK surface; /match stays public, X-API-Key there is soft attribution only

Portal surface (src/portal/portal-api-key.controller.ts): the standard stack ClerkAuthGuard → RolesGuard → RateLimitGuard plus AuditInterceptor, org via @OrgFromUser(OrgResolutionPipe).

  • GET /portal/api-keys (any org role, RateLimitPresets.portal): checks EntitlementsService.canUseFeature(org.id, 'api_access') first. Gated orgs get {sdkKey: null, serverKey: null, locked: true} and no key rows are created. Entitled orgs lazily provision both tiers: ApiKeyService.getKeys → getActiveOrProvision per tier — findFirst on isRevoked: false AND expiresAt: null (newest first), else create with displayKey: rawKey. There is no empty state by design.
  • POST /portal/api-keys/:tier/regenerate (@Roles('developer'), RateLimitPresets.apiKeyRegen): the rotation sequence below. 400 for any tier outside CLIENT|SERVER (ParseEnumPipe).

Storage (src/auth/services/api-key-hash.service.ts): a raw key is an 8-char prefix + 32 random bytes base64url; only its SHA-256 hex is stored in hashedKey. displayKey persists the raw key for both tiers since commit c11a471 — decision D-1 (2026-09-14): the server key is retrievable by design, an accepted trade-off so a lost key never locks an org out. Consequence: masking is portal-UI-only (in-session useState reveal in the key cards; any reload re-masks), while GET /portal/api-keys returns the full key in displayKey. The stale "raw key is returned ONCE" docstring on generate() predates D-1.

Rotation (ApiKeyService.regenerate, src/api-key/api-key.service.ts):

  1. updateMany supersedes every active row of the tier: CLIENT rows get expiresAt = now + 24h (SDK_GRACE_MS — still authenticates; the guard accepts isRevoked: false AND (expiresAt null OR > now)), SERVER rows get isRevoked: true + revokedAt (instant, no grace).
  2. Creates the new row with displayKey: rawKey.
  3. Returns the card plus rawKey (also persisted — see storage).

updateMany (not update) self-heals theoretical duplicate-active races; a ponytail: comment records that no partial unique index on active-per-(org, tier) exists. Legacy rows from before displayKey are products of migration fixed_purpose_api_keys (dropped label/appIdentifier, added displayKey/expiresAt, adopted the newest active key per (org, tier), revoked the extras): they carry displayKey: null and the portal renders the regenerate-to-reveal hint for them. The SERVER legacy branch is code-verified only — it shares the code path live-verified for CLIENT.

Verification (src/auth/guards/api-key-auth.guard.ts): prefix-indexed (@@index([prefix])) findMany over un-revoked, un-expired rows, then timingSafeEqual on the SHA-256; org must be status ACTIVE else 401 "Organization is suspended". Tier enforcement (P7-001) is authorization, not authentication: @RequireApiKeyTier(TIER) metadata (src/auth/decorators/require-api-key-tier.decorator.ts) turns a valid wrong-tier key into 403.

Rate limit: apiKeyRegen = { points: 5, duration: 3600, keyPrefix: 'rl:api-key-regen' }, per IP (src/rate-limiting/rate-limit.decorator.ts:34) → 429 past 5/hour. A 400 bad-tier request also consumes a point because guards run before the ParseEnumPipe.

Audit: @Auditable({ action: 'REGENERATE', entityType: 'ApiKey' }) on regenerate; REGENERATE is a dedicated audit enum value (migration add_audit_regen_action). Keys are minted nowhere else — the legacy admin-bearer surface has no api-keys route.

Test traps: the seeded Demo Org is starter, so GET returns the locked payload — unlock with UPDATE "Organization" SET plan='growth' via psql, and remember the seed resets it. Migrations must be deployed to both databases (optolink_test for e2e). The Node SDK live e2e needs a raw SERVER key: mint via POST /portal/api-keys/SERVER/regenerate and export the response's .rawKey as OPTOLINK_E2E_API_KEY. Don't burn the 5/hour regen budget from shared IPs.

401/403 shapes​

All errors funnel through the GlobalExceptionFilter (src/common/filters/http-exception.filter.ts, registered in src/main.ts), so even guard rejections are uniform JSON, never HTML:

{
"statusCode": 401,
"error": "Unauthorized",
"message": "Missing or invalid Authorization header",
"details": null,
"timestamp": "2026-09-17T12:00:00.000Z",
"path": "/portal/links",
"requestId": "…"
}
ScenarioStatusmessageThrown by
No / non-Bearer Authorization header401Missing or invalid Authorization headerClerkAuthGuard
Token fails signature/expiry/JWKS verification401Invalid Clerk session tokenClerkAuthGuard
Valid session, role claim missing/invalid, or below @Roles minimum403Missing or invalid role claim / Insufficient roleRolesGuard
No org claim, org not mirrored, not a member, or org suspended403User does not belong to a valid organization / User is not a member of this organization / Organization is not activeOrgResolutionPipe
Non-ops session on /portal/admin/* (or CLERK_OPS_ORG_ID unset)403Ops access requiredOpsRoleGuard

Guards run before parameter pipes, so an unauthenticated /portal/* hit always dies in step 2 with the 401 above. This is exactly what the 2026-09-17 live pass observed: unauth /portal/* → clean 401 JSON.

Roles live in claims, not the DB​

Locked decision (AGENTS.md): the per-org role is stored on the Clerk org membership (org_membership.public_metadata.role), injected into every session token by the Clerk JWT template, and read only from the claim at request time. No DB lookup in RolesGuard.

  • Ladder (least → most privileged): viewer → analyst → developer → admin → owner, lowercase strings in claims (src/auth/roles.ts, hasMinimumRole is an inclusive index comparison).
  • DB mirror is the uppercase Prisma enum OrgRole on OrgMember.role (prisma/schema.prisma); ClerkSyncService.mapRole (src/clerk/clerk-sync.service.ts) translates and defaults to VIEWER on anything unrecognised (fail-safe, least privilege).
  • Trade-off accepted: role changes/revoke do not take effect until the member's session token refreshes (≈60 s mint window), because the claim is baked into the JWT. Org membership itself gets a DB backstop on every request via OrgResolutionPipe, so removing a member cuts API access at the next org-scoped call regardless of claims.
  • Prerequisite (instance config): the Clerk instance's JWT template must include "org_metadata": "{{org_membership.public_metadata}}". Without it orgRole is null and every role-gated endpoint returns 403 (documented in both guard files). The same per-instance risk applies to the o/org_id/email claims the guard reads.

Ops org bypass​

RolesGuard lets any session whose active org equals CLERK_OPS_ORG_ID past customer role gates (mirroring OpsRoleGuard), and is fail-closed: when the env var is unset the bypass simply never fires, and OpsRoleGuard (src/auth/guards/ops-role.guard.ts) denies everyone on /portal/admin/* and billing-admin routes. Ops-only authorization therefore rests on one seeded org ID, not on role claims.

Clerk webhooks — Option A org mirroring​

POST /webhooks/clerk (src/clerk/clerk-webhook.controller.ts, tagged Webhooks in Swagger) verifies the svix signature (CLERK_WEBHOOK_SECRET, raw body required via rawBody: true in src/main.ts) and routes to ClerkSyncService (src/clerk/clerk-sync.service.ts):

EventWrite
organization.created / .updatedUpsert Organization by clerkOrgId (generates a unique orgKey on create; plan stays NULL; isOpsOrg: false)
organization.deletedSoft-delete: status → SUSPENDED (retention requirement)
user.created / .updatedUpsert User by clerkUserId; falls back to email lookup to link pre-Clerk rows (seed) instead of failing the unique constraint
organizationMembership.createdUpsert OrgMember; then resolve the authoritative role from the pending invitation's public_metadata.optoRole (default owner for the org creator), stamp it onto the Clerk membership via updateMemberRole, and backfill Organization.contactEmail from the first OWNER
organizationMembership.updatedUpdate OrgMember.role from public_metadata.role (updateMany, race-safe no-op)
organizationMembership.deletedDelete the OrgMember row

Properties worth keeping in mind:

  • Idempotent + order-tolerant — membership handling ensures org and user rows exist first (minimal rows if the user.created/organization.created events haven't landed).
  • Webhook never 500s on role-sync problems — Clerk API calls inside the membership handler are best-effort, wrapped, logged.
  • Sync-on-demand race bridge — ensureOrgFromClerk() fetches the org from the Clerk Backend API when a request arrives before the webhook (the onboarding wizard reads state immediately after createOrganization). Returns null → caller surfaces 403 for a frontend retry.
  • All Clerk Backend API access goes through one shared client (src/clerk/clerk-client.provider.ts) behind ClerkBackendService (src/clerk/clerk-backend.service.ts) — invitations carry public_metadata.optoRole, and the Clerk system role is always org:member (the app role lives in metadata, not Clerk's role system).

Self-registration path​

Sign-up is pure Clerk: the portal's /login//register make zero backend calls (network-verified in both audits). The backend first sees a new user during onboarding:

  1. POST /portal/onboarding/create-organization → PortalOnboardingService.createOrganization (src/portal/portal-onboarding.service.ts, "Option E"):
    • 409 if the caller already belongs to any org (double-submit / org-farming guard; local OrgMember is the source of truth);
    • creates the org in Clerk with createdBy (auto-adds the creator membership and fires the organization.created + organizationMembership.created webhooks, which idempotently confirm the same state);
    • mirrors locally in the same request: Organization (plan: NULL) → User → OrgMember(role: OWNER);
    • stamps publicMetadata.role = 'owner' on the Clerk membership, so the caller's first org-scoped session token already carries the owner role;
    • org-name collision (unique constraint) rolls the just-created Clerk org back (best-effort delete) and surfaces 409; no orphaned Clerk orgs.
  2. Plan selection — selectPlan writes STARTER (the free default) directly to Organization.plan; any other plan key is rejected with UseBillingFlowException (422) and must go through the billing checkout (FLOW-012). Until any plan is selected, plan is NULL and every quota-gated action fails with PlanNotSelectedException — resolved via EntitlementsService, the sole enforcement layer (src/entitlements/).

There is no separate "register" endpoint and no email/password code in this repo. Credential handling, verification, and bot protection are entirely Clerk.

Data model (auth surface)​

prisma/schema.prisma:

  • Organization — clerkOrgId String? @unique, orgKey @unique, status ACTIVE|SUSPENDED, isOpsOrg Boolean, plan String? (NULL pre-selection; only plan selection — the free STARTER default — and the billing engine write it), contactEmail (backfilled from the first OWNER membership).
  • User — clerkUserId String? @unique, email @unique, name, lastLoginAt. ⚠️ User.role (ADMIN|USER) is the coarse Phase-0 in-app flag, not the RBAC role; the RBAC role lives on OrgMember.role and in claims.
  • OrgMember — join table, @@unique([orgId, userId]), role OrgRole (uppercase enum). Canonical source of membership (multi-org-ready; v2 scope is one org per user).

Env dependencies​

src/config/env.validation.ts. Required at boot (fail-fast, all missing listed): CLERK_SECRET_KEY, CLERK_WEBHOOK_SECRET. Optional, fail-closed: CLERK_OPS_ORG_ID (ops/admin routes deny everyone when unset). Test-only: RATE_LIMIT_DISABLED. The single ClerkClient is built from CLERK_SECRET_KEY (src/clerk/clerk-client.provider.ts).

Swagger surface​

src/main.ts declares the ClerkAuth bearer security scheme ("Clerk session JWT (Authorization: Bearer <clerk_token>)") plus AdminAuth and ApiKeyAuth; portal controllers annotate @ApiSecurity('ClerkAuth') (e.g. src/team/team.controller.ts, src/portal/portal-admin-*.controller.ts) and document 401/403 per endpoint via @ApiResponse. The webhook controller sits under the Webhooks tag with its 200/400 contract. Auth itself adds no endpoints to the customer API.

Events & audit (auth-adjacent)​

  • No auth-related domain events exist. The only emitted events, click.tracked and match.found, are listenerless since Phase 5.1 (AGENTS.md). Auth state changes propagate exclusively through webhooks and claims.
  • The AuditInterceptor actor chain (src/audit/audit.interceptor.ts) resolves actors with priority ADMIN_PASSTHROUGH (ops session + ?orgId) → ADMIN (ops session) → USER (Clerk session) → API_KEY → admin bearer → SYSTEM.

What the 2026-09-17 live pass verified (deployed stack)​

  • Unauthenticated /portal/* → clean 401 JSON with Missing or invalid Authorization header, matching ClerkAuthGuard's first branch exactly.
  • /login + /register make zero /portal/* calls; the first portal API traffic (onboarding/state, dashboard) fires only after the post-auth redirect split.
  • Clerk→DB webhook mirroring verified on the deployed instance (QA org provisioning, wayfinder ticket 09).
  • LIVE-1: the sign-in card heading "Sign in to Optolink" (wrong casing) is the Clerk instance display name: instance config, not app code. Same class as any per-environment dashboard/Platform-API one-off: it must be repeated per Clerk instance (local evident-terrapin-22 vs deployed active-shrimp-6400).

Accepted trade-offs​

  • Roles in claims, not DB — zero per-request RBAC lookups at the cost of ≲60 s propagation latency (until token refresh) for role changes; membership has a DB backstop.
  • Custom-claim JWT template — the guard depends on instance-level template claims (org_metadata, o/org_id, email); a wrongly-templated instance degrades to 403s, not wrong-role access (fail-safe direction).
  • Webhook eventual consistency — bridged per-request by ensureOrgFromClerk on the one hot path (onboarding); everywhere else a few seconds of mirror lag is acceptable.
  • Single-org scope — OrgMember join table is multi-org-shaped, but v2 enforces one org per user at the API layer (409 on second org).
  • Retrievable server key (D-1) — displayKey persisted for both tiers means the server secret can always be re-read from the portal; the cost is that "masked" is a UI courtesy, not a security property. Anyone with portal access (any role) can read the full server key.
  • Ops = org-ID, not role — ops authorization is possession of a session in the seeded ops org; simple, auditable, fail-closed when unconfigured.