Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureAuthentication & identity

Authentication & identity

Clerk owns identity end to end. Web, mobile, and admin all sign users in through the same Clerk instance and the same publishable key; the API verifies Clerk JWTs and looks up the matching DB user. The DB stores its own row keyed by clerkId, populated via webhook on user creation.

Stack at a glance

LayerLibraryPurpose
Web middleware@clerk/nextjs/server clerkMiddlewareSession, redirect to /sign-in, protect routes
Web client@clerk/nextjs<SignIn/>, <UserButton/>, useUser, useAuth, useOrganization
Web server@clerk/nextjs/server auth()Bearer token retrieval for serverFetch
Mobile client@clerk/clerk-expo + @clerk/localizationsClerkProvider, useAuth, useClerk; token cache via expo-secure-store
API verify@clerk/backend verifyTokenJWT signature + claims check
API client@clerk/backend createClerkClientDI token CLERK_CLIENT for admin actions (org creation, invites, deletes)
Webhook verifysvixVerifies Clerk’s outgoing webhook signatures
E2E@clerk/testingCached sign-in for Playwright

Web side

Middleware

apps/web/src/middleware.ts wraps a custom handler with clerkMiddleware. Protected routes are matched via createRouteMatcher:

/:lang /:lang/schedule(.*) /:lang/workouts(.*) /:lang/shop(.*) /:lang/courses(.*) /:lang/profile(.*) /:lang/dashboard(.*) /:lang/onboarding(.*) /:lang/complete-profile(.*)

For these, auth.protect() is invoked — Clerk redirects unauthenticated users to NEXT_PUBLIC_CLERK_SIGN_IN_URL (default /sign-in). Public routes (/[lang]/buy/..., /[lang]/auth/reset) are untouched.

E2E bypass

When NEXT_PUBLIC_E2E_TEST_MODE === 'true', auth.protect() is not invoked. This is the only way the middleware lets unauthenticated traffic through. Combined with TEST_AUTH_BYPASS=true on the API (and an x-test-user-id header pointing at a seeded clerk id), E2E specs can swap personas without going through real Clerk auth on every spec.

Bearer token attachment

  • Server components: serverFetch(path) calls auth() from @clerk/nextjs/server, then getToken() to mint a short-lived JWT. Attached as Authorization: Bearer <token>.
  • Client components: the useApi hook does the same on the client via useAuth().getToken() and feeds it into TanStack Query.

If the API returns 401, serverFetch redirects to a locale-aware auth-reset path (apps/web/src/lib/auth-reset.ts) so a stale token doesn’t render an error page — it kicks the user back to sign-in cleanly.

Mobile side

fitkit-mobile uses @clerk/clerk-expo with a SecureStore-backed token cache. Same publishable key as web; same Clerk instance; same JWT format.

Token cache (expo-secure-store)

src/lib/secure-token-cache.ts implements Clerk’s TokenCache interface against expo-secure-store. Without this, Clerk drops sessions on every cold start — SecureStore (and AsyncStorage as a fallback) is the closest mobile equivalent of a session cookie. Corrupted entries are deleted and re-issued silently.

useApi hook

src/hooks/use-api.ts. Same envelope as web’s hook with two mobile-specific twists:

  • 10-second in-memory JWT cache. Clerk JWTs are valid for 60s; the cache amortizes the bridge cost of getToken() across rapid sequential calls.
  • Single 401 retry then sign-out. First 401 → force-refresh the token and retry; second 401 → signOut() + router.replace('/(auth)/sign-in'). No locale-aware redirect — the mobile auth screen handles locale itself.
  • X-Locale header. Set on every request from useI18n().lang so the API can localize notification payloads.

Push token revocation on sign-out

The Profile → Sign out flow calls revokeCurrentDeviceToken() before signOut() so the device stops receiving push notifications meant for the previous user. The push token is cached at module scope (usePushNotifications) so this works after Clerk has already cleared credentials.

Invitation acceptance

Clerk invitation emails point at clerk.fitkit.fit/v1/tickets/accept. After server-side validation Clerk redirects to https://app.fitkit.fit/sign-up?__clerk_ticket=...&__clerk_status=sign_up. On mobile this is caught by:

  • iOS: associated domain applinks:app.fitkit.fit (AASA file required).
  • Android: intent filter for pathPrefix: '/sign-up' with autoVerify: true (assetlinks.json required).

See FIT-188 for the asset-file setup. The screen at app/sign-up.tsx consumes the ticket and completes Clerk’s flow.

See also

Deep dive in mobile.md — covers the full Expo Router auth-gate chain, push lifecycle, and deep-link table.

API side

CLERK_CLIENT

Module: apps/api/src/auth/auth.module.ts — global. Provides:

export const CLERK_CLIENT = 'CLERK_CLIENT';

Resolves to createClerkClient({ secretKey, publishableKey }). Inject it where you need to drive Clerk (create organizations, send invitations via the Clerk API, delete a Clerk user when a DB user is removed).

AuthGuard

apps/api/src/auth/auth.guard.ts — registered as a global APP_GUARD. On every request:

  1. If the handler has @Public(), allow.
  2. If TEST_AUTH_BYPASS is on (non-prod only) and x-test-user-id is present, populate request.auth from the test headers + a DB lookup. Allow.
  3. Read Authorization: Bearer <token>. 401 if missing.
  4. verifyToken(token, { secretKey: CLERK_SECRET_KEY }) (from @clerk/backend).
  5. Look up the DB user by clerkId.
  6. Populate request.auth = { userId: payload.sub, sessionId: payload.sid, user: dbUser ?? null }.
  7. Set Sentry user (Sentry.setUser({ id: payload.sub })).
  8. Allow.

request.auth.user can be null — a user might have a Clerk session but no DB row yet (the webhook hasn’t fired, or fired and failed). Controllers must defend against this; the /users/me endpoint specifically handles the recovery path (find-or-create).

@CurrentUser decorator

apps/api/src/auth/current-user.decorator.ts:

@Get('me') async me(@CurrentUser() auth: AuthPayload) { ... } @Get('something') async something(@CurrentUser('userId') userId: string) { ... }

AuthPayload is { userId: string; sessionId: string; role?: string; user: UserRecord | null }. userId is the Clerk subject (user_xxx), not the DB UUID. Use auth.user.id for the DB primary key.

@Public decorator

apps/api/src/auth/public.decorator.tsSetMetadata(IS_PUBLIC_KEY, true). Used on:

  • HealthController (/health).
  • ClerkWebhookController (/webhooks/clerk).
  • Public purchase / signup endpoints.
  • Form signing endpoints (token-authenticated, not bearer-authenticated).

User sync via Clerk webhooks

Endpoint: POST /webhooks/clerkapps/api/src/webhooks/clerk-webhook.controller.ts.

Verification is svix-based against CLERK_WEBHOOK_SECRET. The NestJS app boots with rawBody: true so the unparsed body is available for signature verification.

Clerk eventWhat happens in our DB
user.createdUsersService.findOrCreateFromClerk(clerkId) — links or creates a users row. Fires PostHog user_signed_up. Auto-accepts any matching pending invitations (MembershipsService.acceptPendingInvitations(clerkId)).
user.updatedUsersService.syncFromClerk(clerkId, { email, imageUrl }) — keeps email + avatar mirrored.
user.deletedUsersService.deleteSelf(clerkId, { skipClerk: true }) — soft-deletes the DB user. skipClerk prevents recursive Clerk delete.

Webhook failures are surfaced via Sentry breadcrumbs and Pino warn/error logs.

Multi-org pattern

Clerk Organizations is not used. The active-org concept is app-managed:

  • A user (users row) can belong to many orgs via multiple memberships rows.
  • memberships.role: owner | admin | coach | member (libs/db/src/lib/schema/enums.ts: membershipRole).
  • memberships.status: active | invited | pending_invitation | suspended | cancelled.
  • The web app’s user provider (apps/web/src/providers/user-provider.tsx) loads the user + their memberships from /users/me and exposes the active org context.
  • The API resolves the target org from the URL (:orgId path param) or from the membership context, not from a JWT claim.

This decouples Clerk’s product surface (which is geared at SaaS-style B2B with its own UI) from FitKit’s data model (membership rows already do the job).

Roles

membershipRole enum values:

RoleWhat it can do
ownerFull org control. One per org. Cannot be removed by anyone but a Platform Admin.
adminOwner-equivalent except cannot change billing or delete the org. Owner-delegated.
coachBuild workouts, run sessions, message members, view (but not modify) billing.
memberMember surfaces only. Cannot see other members’ data.

Role authorization is enforced inside each service — there’s no global role guard. Pattern: services accept a userId (from @CurrentUser('userId')) and look up the membership for the target org, then branch on role.

Invitations

Schema: libs/db/src/lib/schema/invitations.ts. Two paths exist depending on whether the invitee has a Clerk account:

  1. No Clerk account yet. API creates an invitation row, sends an email with a Clerk __clerk_ticket token. Invitee signs up via Clerk → Clerk fires user.created → webhook auto-accepts the matching pending invitation (MembershipsService.acceptPendingInvitations).
  2. Existing Clerk user. API creates an invitation row and a pending_invitation-status membership immediately. User accepts in-app.

Lifecycle (invitationStatus enum): pending → accepted | revoked | expired.

Invitation tokens are signed with INVITE_SECRET.

Encryption-adjacent: national IDs

Israeli Teudat Zehut numbers (where collected) are encrypted with NATIONAL_ID_ENCRYPTION_KEY before storage. Service: apps/api/src/users/national-id-encryption.service.ts. Treated as PII — never logged, never returned in normal responses, returned only when explicitly needed for compliance PDFs (and even then to staff-authorized endpoints only).

E2E auth caching

apps/web/e2e/global.setup.ts does a real Clerk sign-in once per CI run with E2E_CLERK_USER_EMAIL / E2E_CLERK_USER_PASSWORD, then writes e2e/.auth/signed-in-state.json (cookies + local storage). Specs reuse it via Playwright’s storageState mechanism. Per-spec persona swaps happen by changing the x-test-user-id header the spec sends, not by re-signing-in.

Failure modes you’ll see in CI:

  • E2E_CLERK_USER_EMAIL env var is required — secret not propagated.
  • Sign-in step times out — Clerk catchall verifier is slow on cold runs; the setup gives it 180s.

Reference env vars

# API CLERK_SECRET_KEY required — verifies tokens, drives admin API CLERK_PUBLISHABLE_KEY required — exposed to the SDK CLERK_WEBHOOK_SECRET required for /webhooks/clerk verification INVITE_SECRET invitation token signing # Web NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY required client-side CLERK_SECRET_KEY used by Next middleware (server-only) NEXT_PUBLIC_CLERK_SIGN_IN_URL default /sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL default /sign-up # Test TEST_AUTH_BYPASS non-prod only — allows x-test-* headers NEXT_PUBLIC_E2E_TEST_MODE skips Clerk protect in middleware E2E_CLERK_USER_EMAIL real Clerk user used by Playwright setup E2E_CLERK_USER_PASSWORD E2E_CLERK_USER_ID