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
| Layer | Library | Purpose |
|---|---|---|
| Web middleware | @clerk/nextjs/server clerkMiddleware | Session, 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/localizations | ClerkProvider, useAuth, useClerk; token cache via expo-secure-store |
| API verify | @clerk/backend verifyToken | JWT signature + claims check |
| API client | @clerk/backend createClerkClient | DI token CLERK_CLIENT for admin actions (org creation, invites, deletes) |
| Webhook verify | svix | Verifies Clerk’s outgoing webhook signatures |
| E2E | @clerk/testing | Cached 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)callsauth()from@clerk/nextjs/server, thengetToken()to mint a short-lived JWT. Attached asAuthorization: Bearer <token>. - Client components: the
useApihook does the same on the client viauseAuth().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-Localeheader. Set on every request fromuseI18n().langso 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'withautoVerify: 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:
- If the handler has
@Public(), allow. - If
TEST_AUTH_BYPASSis on (non-prod only) andx-test-user-idis present, populaterequest.authfrom the test headers + a DB lookup. Allow. - Read
Authorization: Bearer <token>. 401 if missing. verifyToken(token, { secretKey: CLERK_SECRET_KEY })(from@clerk/backend).- Look up the DB user by
clerkId. - Populate
request.auth = { userId: payload.sub, sessionId: payload.sid, user: dbUser ?? null }. - Set Sentry user (
Sentry.setUser({ id: payload.sub })). - 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.ts — SetMetadata(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/clerk — apps/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 event | What happens in our DB |
|---|---|
user.created | UsersService.findOrCreateFromClerk(clerkId) — links or creates a users row. Fires PostHog user_signed_up. Auto-accepts any matching pending invitations (MembershipsService.acceptPendingInvitations(clerkId)). |
user.updated | UsersService.syncFromClerk(clerkId, { email, imageUrl }) — keeps email + avatar mirrored. |
user.deleted | UsersService.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 (
usersrow) can belong to many orgs via multiplemembershipsrows. 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/meand exposes the active org context. - The API resolves the target org from the URL (
:orgIdpath 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:
| Role | What it can do |
|---|---|
owner | Full org control. One per org. Cannot be removed by anyone but a Platform Admin. |
admin | Owner-equivalent except cannot change billing or delete the org. Owner-delegated. |
coach | Build workouts, run sessions, message members, view (but not modify) billing. |
member | Member 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:
- No Clerk account yet. API creates an invitation row, sends an email with a Clerk
__clerk_tickettoken. Invitee signs up via Clerk → Clerk firesuser.created→ webhook auto-accepts the matching pending invitation (MembershipsService.acceptPendingInvitations). - 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