Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesUsers AuthUsers & Authentication

Users & Authentication

What is this

FitKit’s identity stack is Clerk-backed: Clerk owns the credential and session, FitKit owns the application-level profile (users table). The two stay in sync via a Svix-verified webhook (apps/api/src/webhooks/clerk-webhook.controller.ts) and a small set of direct Clerk SDK calls. Every authenticated request flows through AuthGuard (apps/api/src/auth/auth.guard.ts), which verifies the bearer JWT with Clerk and attaches { userId, sessionId, user } to the request.

The users row is the single, global identity. Org-scoped attributes live in memberships and member_profiles. The two profile tables are intentionally separate: users holds personal identity (name, phone, DOB, gender, emergency contact, Israeli national ID encrypted at rest), while member_profiles holds gym-specific data (RFID tag, allow_sms, allow_mailing_list, medical_cert, etc.) per (user, org).

Who uses it

PersonaWhy
AnyoneSign-up, sign-in, profile edit.
MemberUpdate own profile, complete required fields, accept legal consents, optionally self-delete (App Store 5.1.1(v)).
Owner / Admin / CoachSame as member for their own user; in addition, can view+update other org members’ profiles via the memberships module.
PlatformClerk webhook syncs Clerk users into the DB on create / update / delete.

Persona impact

  • First-time sign-in — if no DB row exists yet, findOrCreateFromClerk lazily creates it (or links an imported user where the row exists with clerk_id=NULL but the same email).
  • Imported member onboarding — Arbox/CSV import creates user rows with clerk_id=NULL. The first sign-in (via Clerk invitation) attaches the existing row to the Clerk identity. Names imported are not overwritten by Clerk values.
  • Profile completeness gateisProfileComplete(user) checks the 7 required fields; the dashboard nudges members to complete-profile if any are missing.
  • Account deletiondeleteSelf soft-deletes the user, cancels active subs (cancelAtPeriodEnd=true), soft-deletes memberships and device tokens, then deletes the Clerk identity. Idempotent. Webhook user.deleted calls it back with skipClerk:true to avoid recursion.

High-level capabilities

  1. Clerk webhook sync — handles user.created, user.updated, user.deleted. Verified via Svix; Public endpoint.
  2. Lazy provisioningGET /users/me falls back to findOrCreateFromClerk if the AuthGuard didn’t already attach a user row (rare: first request before webhook lands).
  3. Profile readGET /users/me returns user + memberships + pending consents + profile-complete flag + masked national ID.
  4. Profile updatePATCH /users/me. Names also synced back to Clerk; phone normalized to Israeli format if recognizable; national ID validated (Israeli ID checksum) and encrypted before storage.
  5. Account self-deletionDELETE /users/me. Cascades softly across memberships, subs, device tokens, then deletes Clerk identity.
  6. Test bypass — when TEST_AUTH_BYPASS=true and non-prod, headers x-test-user-id / x-test-session-id / x-test-role short-circuit the JWT verify. Used in CI/Playwright.
  7. Invitation auto-accept — first sign-in triggers acceptPendingInvitations(clerkId) from the webhook + a fallback on GET /users/me if no active membership exists.

Relationship to other features

  • membershipsmemberships rows link users to organizations with role + status. GET /users/me returns memberships nested.
  • organizations — creation calls Clerk’s createOrganization and stores clerk_organization_id.
  • onboarding — first-time owner runs the wizard; acceptPendingInvitations converts pending_invitation memberships to active.
  • Legal / consentshasOutstandingConsents gates UI on the user’s pending legal documents (terms/privacy/waiver).
  • National ID encryptionNationalIdEncryptionService (envelope encryption; key from env). Mask format: ***1234.

Current status

Shipped. Notable design choices:

  • Email and imageUrl are Clerk-authoritativesyncFromClerk only writes these. Names are explicitly not overwritten by Clerk on user.updated (comment: “Names are managed exclusively in FitKit and are never overwritten from Clerk.”).
  • Phone normalizationnormalizeIsraeliPhone from @fitkit/shared runs on every profile update; falls back to the raw value if the input isn’t recognizable as Israeli.
  • National ID — validated via isValidIsraeliId (Teudat Zehut checksum), stored encrypted, only masked on read.
  • AuthGuard caches the DB user on the request — controllers prefer @CurrentUser('user') over re-fetching.

Known gaps

  • findByClerkId has an in-memory 30 s TTL cache (USER_CACHE_TTL_MS=30_000). Profile updates invalidate it; webhook updates likewise. Across multiple API pods the cache is per-process. (Comment in code.)
  • The webhook handler is single-shot — no retries, no idempotency table. Svix delivers at-least-once, the operations are idempotent (findOrCreate, status updates) but a duplicate user.deleted will log a warning rather than fail.
  • syncToClerk only pushes firstName/lastName. Profile edits to phone/DOB/etc. live only in FitKit.
  • No SSO / SAML configured at the FitKit layer; if Clerk-side SSO is enabled it just works.