Skip to content

Authentication Architecture

Ordermatic uses a split client/server Clerk architecture in apps/webapp combining @clerk/astro for client-side session token refresh with @repo/auth (packages/auth) for server-side SSR handshakes, Session JWT V2 verification, multi-tier caching, headless client actions, and centralized RBAC.


Browser (apps/webapp)
β”œβ”€ @clerk/astro integration (astro.config.mjs)
β”‚ └─ auto-injects clerk-js bootstrap script β†’ keeps __session JWT cookie fresh
β”œβ”€ @repo/auth/headless & @repo/auth/client
β”‚ └─ imperative client actions (signOut, redirectToSignIn, satellite domain sync)
↓ HTTP Request (Cookie: __session, __clerk_db_jwt)
Astro Middleware (apps/webapp/src/middleware.ts β†’ @repo/auth/middleware)
β”œβ”€ Page routes: authenticateRequestForSSR(request)
β”‚ β”œβ”€ Valid JWT β†’ verifyToken + signedInAuthObject (Clerk Session JWT V2 o.id/o.rol/o.slg)
β”‚ β”œβ”€ Expired-but-refreshable JWT β†’ returns Clerk 307 handshake redirect (never bounces to /sign-in)
β”‚ └─ Resolves user & organization via in-memory session-cache + Redis cache (5m TTL)
β”œβ”€ API routes: authenticateRequest(request)
β”‚ └─ Fast-path JWT verification without HTML handshake redirects
└─ Populates Astro.locals.{auth, user, organization, authStatus}

apps/webapp/astro.config.mjs registers clerk() from @clerk/astro:

apps/webapp/astro.config.mjs
import clerk from '@clerk/astro';
import react from '@astrojs/react';
export default defineConfig({
// IMPORTANT: react() MUST precede clerk().
// @clerk/astro registers a before-hydration script with a top-level
// `await runInjectionScript(...)`. If clerk() runs first, that async module
// blocks Vite from executing the @astrojs/react fast-refresh preamble before
// React islands evaluate in dev.
integrations: [react(), clerk(), tailwindcss()],
});
  • Automatic __session refresh: @clerk/astro injects the clerk-js bootstrap script into every page and runs background token refresh timers so the short-lived __session JWT cookie stays fresh while a tab is open.
  • No React Clerk islands: Because @clerk/astro handles client initialization natively, AppChrome.astro mounts zero Clerk islands (only FeatureFlagInitializer, PostHogInit, and PostHogPageView).
  • Retired legacy client stack (PR #2640): The legacy bespoke client Clerk stack (ClerkInitializer.tsx, services/authSync.ts, hooks/useAuth.ts, stores/clerkStore.ts, AuthPerformanceMonitor.tsx, and AuthLoadingBoundary.tsx) has been completely removed.

Headless Client Actions (@repo/auth/headless & @repo/auth/client)

Section titled β€œHeadless Client Actions (@repo/auth/headless & @repo/auth/client)”

When client components need imperative auth actions (such as signing out from RailNav.tsx or redirecting unauthenticated users), they use @repo/auth/headless (packages/auth/src/headless.ts) and @repo/auth/client (packages/auth/src/client.ts):

  • initializeClerk(publishableKey) β€” singleton access to window.Clerk (or @clerk/clerk-js fallback).
  • signOut() β€” clears the Clerk session and redirects to /sign-in.
  • redirectToSignIn() / redirectToSignUp() β€” supports satellite domains (staging.ordermatic.co syncing with primary app.ordermatic.co when PUBLIC_CLERK_IS_SATELLITE=true).

While @clerk/astro manages the browser bootstrap, apps/webapp deliberately uses @repo/auth/middleware (createAuthMiddleware) on the server rather than @clerk/astro’s default clerkMiddleware(). This preserves Ordermatic’s multi-tier caching, Session JWT V2 claim normalization, 4-state locals.authStatus contract, and custom SSR handshake handling.

Import Path File Purpose
@repo/auth/middleware packages/auth/src/middleware.ts createAuthMiddleware factory, route matchers, security headers, and locals.authStatus contract
@repo/auth/server packages/auth/src/server.ts authenticateRequest, authenticateRequestForSSR, Session JWT V2 verification, Redis user/org cache, Clerk backend API wrappers
@repo/auth/astro packages/auth/src/astro.ts Astro route/API guards (getUser, getOrganization, requireAuth, requireAdmin, requireOrgAdmin, requireSysAdmin, isSysAdmin)
@repo/auth/rbac packages/auth/src/rbac.ts Centralized role & metadata evaluators (hasPlatformSysAdminRole, hasWebappOrgAdminNavMetadata, isInternalUser)
@repo/auth/roles packages/auth/src/roles.ts Canonical role constants (ROLES.SYS_ADMIN, ROLES.ADMIN, ROLES.INTERNAL_ADMIN, ROLES.ORG_ADMIN, ROLES.MEMBER)
@repo/auth/headless packages/auth/src/headless.ts Headless browser Clerk helpers (initializeClerk, signOut, getSessionToken)
@repo/auth/client packages/auth/src/client.ts Satellite-aware browser redirects (redirectToSignIn, redirectToSignUp)

SSR Handshake vs. API Verification (packages/auth/src/server.ts)

Section titled β€œSSR Handshake vs. API Verification (packages/auth/src/server.ts)”

Clerk’s __session cookie is a short-lived (~60s) JWT. When a user returns to an idle tab or navigates after __session expires, the cookie is stale even though their Clerk session is still active.

  1. Page routes call authenticateRequestForSSR(request):
    • First checks the in-memory session-cache.ts (keyed by raw __session token).
    • Otherwise calls clerkClient.authenticateRequest(request, { secretKey, publishableKey, jwtKey }).
    • If Clerk returns status === 'handshake': authenticateRequestForSSR extracts the 307 redirect Location and Set-Cookie headers from requestState.toAuth() and returns { handshakeRedirect: Response }. createAuthMiddleware immediately returns that 307 response so the browser completes the silent Clerk handshake and lands back on the requested URL with a fresh __session cookie β€” never bouncing a signed-in user to /sign-in.
  2. API routes (/api/*) call authenticateRequest(request):
    • Uses fast-path verifyToken(token, { secretKey, jwtKey }) + signedInAuthObject(...) from @clerk/backend/internal without triggering HTML handshake redirects.
    • Falls back to clerkClient.authenticateRequest if verifyToken fails.

Clerk Session JWT V2 nests organization claims inside an compact o object (o.id, o.rol, o.slg) instead of top-level org_id / org_role / org_slug claims. Calling verifyToken() raw leaves payload.org_id undefined.

@repo/auth/server passes the verified payload through Clerk’s signedInAuthObject( { ... }, token, payload ), which normalizes both v1 and v2 JWTs into { userId, sessionId, orgId, orgRole, orgSlug } before constructing the User and Organization objects.

To keep SSR latency under a few milliseconds and avoid Clerk Backend API rate limits (429 Too Many Requests):

  1. In-memory Session Cache (packages/auth/src/session-cache.ts): Caches the resolved { user, organization } keyed by the exact __session JWT string for the lifetime of that short-lived token.
  2. Redis User Cache (auth:user:${userId}, 5-minute TTL): Caches the Clerk User profile (email, firstName, lastName, imageUrl, publicMetadata, privateMetadata) via @repo/utils/cache.
  3. Redis Organization Cache (getCachedOrganization, 5-minute TTL): Caches organization metadata (id, name, slug, publicMetadata) and deduplicates concurrent in-flight lookups via inFlightOrgRequests.

createAuthMiddleware sets four explicit states on Astro.locals.authStatus:

locals.authStatus Meaning Middleware Behavior on Protected Page Routes
'authenticated' Valid session JWT and locals.user resolved Proceeds to render page
'authenticated_no_user' Valid session JWT (locals.auth.userId present), but Clerk Backend API failed/timed out fetching User Proceeds (or lets route handle degraded profile) β€” does not redirect to /sign-in, preventing redirect loops
'unauthenticated' No valid session and no handshake possible Redirects to /sign-in?redirect_url=...
'auth_error' Unexpected exception during auth middleware execution Logs error; redirects protected page routes to /sign-in

3. Role-Based Access Control (@repo/auth/rbac & @repo/auth/astro)

Section titled β€œ3. Role-Based Access Control (@repo/auth/rbac & @repo/auth/astro)”

Role checks are centralized in packages/auth/src/rbac.ts and exposed to Astro pages/endpoints via @repo/auth/astro:

  • Platform System Admin (isSysAdmin(locals) / requireSysAdmin(locals)): Checks hasPlatformSysAdminRole(user.publicMetadata) (sys_admin or sys-admin in publicMetadata.role / publicMetadata.roles). Required for /admin/* platform routes and cross-tenant org switching.
  • Organization Admin (isOrgAdmin(locals) / requireOrgAdmin(locals)): True if the user is a platform sys_admin, holds Clerk organization role org:admin (locals.auth.orgRole === 'org:admin'), or has tenant admin metadata (hasMetadataTenantAdminRole). Gates organization admin settings and navigation items in AppRail.astro.
  • Internal Staff (isInternalUser(user)): Used by apps/internal (team.ordermatic.co) to verify @ordermatic.co domain membership plus sys_admin, admin, or internal_admin roles.

Surface Mechanism
apps/webapp (app.ordermatic.co) @clerk/astro client bootstrap + @repo/auth SSR middleware (authenticateRequestForSSR, Session JWT V2, Redis cache)
apps/internal (team.ordermatic.co) Cloudflare Access (@ordermatic.co) β†’ Cloudflare Tunnel β†’ erp-prod-internal-edge (mints GCP Cloud Run ID token) β†’ @repo/auth Clerk verification (isInternalUser)
Webapp Internal APIs Shared service token (INTERNAL_SERVICE_TOKEN / WEBAPP_API_KEY) verified via constant-time comparison (validateInternalServiceToken in @repo/utils)
partner-data-api Dual-layer: Cloudflare Access service token JWT at transport edge + app-issued partner API token (Authorization: Bearer, mcp_tokens row scoped to a single connection_id)
graph-mcp Cloudflare Access (@ordermatic.co Zero Trust policy)
content-mcp Static bearer token (MCP_AUTH_TOKEN)