Skip to content

ADR 0002: Clerk for user authentication

Rhumbatron needs user sign-in and a product sign-up flow for end users at https://rhumbatron.localhost.

Rhumbatron uses Clerk (application Rhumbatron) with @clerk/tanstack-react-start in apps/web. The Compose stack uses a Clerk development instance. Clerk development keys accept the host rhumbatron.localhost.

  • src/start.ts runs clerkMiddleware() after the server-function CSRF middleware.
  • ClerkProvider wraps the body contents in src/routes/__root.tsx.
  • /sign-in and /sign-up host the Clerk components.
  • When the server sees a visitor as signed out, src/client.tsx delays the Clerk browser script (src/lib/clerk-deferral.ts). The script loads on the first pointer, touch, key, or focus event, or at the Clerk 15 s script timeout. Clerk loads at once on /sign-in and /sign-up, with a non-zero __client_uat cookie, or with a __clerk URL parameter. Server auth is the same in all cases.
  • The API verifies tokens with @clerk/backend verifyToken (workers/api/src/auth.ts). It checks azp against CLERK_AUTHORIZED_PARTIES (https://rhumbatron.localhost). A token without azp passes only when CLERK_ALLOW_MISSING_AZP is "true". compose.yaml sets "true", because the development instance mints Backend API session tokens without azp for the scripts.
  • apps/web/.env.local holds the development keys, written by clerk init. Git ignores it.
  • bun run stack:env (scripts/local-env.ts) copies the keys into .env. Git ignores .env. Docker Compose gives CLERK_SECRET_KEY to the web and core services at runtime. The value never appears in an image or in source.
  • The publishable key is public. The web image build inlines it as the build argument VITE_CLERK_PUBLISHABLE_KEY. The web service also gets it at runtime as CLERK_PUBLISHABLE_KEY.
  • Rhumbatron code does all authorization. Clerk proves identity only.
  • Every /v1 route of the API runs requireAuth, except /v1/version. /health and /v1/version return only the service name and environment.

One API call from a page. The browser never calls the API directly. Server functions call the core service at API_INTERNAL_URL through apiFetch (apps/web/src/lib/server-env.ts).

sequenceDiagram
  participant B as Browser
  participant C as Clerk
  participant W as Web server (apps/web)
  participant A as API in core (requireAuth)
  participant D as Product database
  B->>C: Sign in on /sign-in (Clerk components)
  C-->>B: Session
  B->>W: Request (clerkMiddleware after CSRF middleware)
  W->>W: Server function calls auth().getToken()
  W->>A: apiFetch to API_INTERNAL_URL with Authorization Bearer token
  A->>A: verifyToken(CLERK_SECRET_KEY), then azp check (CLERK_AUTHORIZED_PARTIES)
  A->>D: ensureIdentity (users, organizations, memberships)
  A->>D: projectRole (organization membership or project_members row)
  A-->>W: JSON response, or 401 on any failed check