Skip to content

Clerk Integration ​

Stale post Layer-1.24 Go cleanup — pending holistic rewrite. This page predates the verifier/resolver/subject split: the Clerk-specific code now lives in internal/core/auth/clerk/ (a Verifier implementation), the principal lookup is human.Resolver in internal/core/domain/human/, and humans.clerk_user_id is now the provider-agnostic humans.provider_subject_id. Clerk is the only verifier today, but the rest of the platform sees only auth.Verifier / principal.Subject — swapping providers is a one-package change. Verify against the code before quoting. Authoritative pointer: decisions.md → Auth chain shape.

Status — JIT-only today; webhook sync is planned. Today's only sync mechanism is JIT (just-in-time) provisioning in the human.Resolver (composed by middleware.Authenticate): the first authenticated API call from a Clerk identity creates the internal principals + humans rows in a single transaction by fetching the profile from the Clerk Backend API. There is no POST /webhooks/clerk endpoint wired in services/api/internal/core/server/routes.go, no Svix-* signature verification path, no CLERK_WEBHOOK_SECRET consumer in internal/core/config/. The webhook sections below describe the planned fallback for when Clerk-side mutations (delete, email change) need to propagate without waiting for the user's next sign-in. Deletions in Clerk currently must be propagated by hand — see Troubleshooting.

Overview ​

Clerk handles all authentication for RestartiX Platform. We use Clerk for signup, login, MFA, password reset, social auth, and session management. Our internal humans table stores app-specific profile data; the FK pointing at the universal principals table makes the human a first-class actor across audit, RBAC, and RLS. The link to Clerk lives on humans.clerk_user_id.

Why Clerk? ​

  • ✅ SOC 2 Type II compliant - Enterprise-grade security
  • ✅ HIPAA BAA available - Enterprise plan includes BAA signing
  • ✅ Pre-built UI components - Drop-in signup/login forms
  • ✅ Session management - Short-lived tokens, automatic rotation
  • 🚧 Webhook-based sync (planned) - Will let Clerk-side deletes / email changes propagate to our DB without waiting for the user's next sign-in
  • ✅ Social auth - Google, Apple, etc. (if needed)

Architecture ​

┌─────────────────┐           ┌─────────────────┐           ┌─────────────────┐
│   Client App    │           │   Go API (API) │           │      Clerk      │
└────────┬────────┘           └────────┬────────┘           └────────┬────────┘
         │                              │                              │
         │──────  Login/Signup  ───────────────────────────────────────→│
         │                              │                              │
         │←───  Session Token  ─────────────────────────────────────────│
         │                              │                              │
         │                              │                              │
         │── API Request ──────────────→│                              │
         │   Authorization: Bearer      │                              │
         │   <clerk_session_token>      │                              │
         │                              │                              │
         │                              │──── Verify Token ───────────→│
         │                              │                              │
         │                              │←── Claims (clerk_user_id) ───│
         │                              │                              │
         │                              │  1. Lookup internal user     │
         │                              │  2. Load role, org, links    │
         │                              │  3. Set RLS session vars     │
         │                              │  4. Execute query            │
         │                              │                              │
         │←────── Response ─────────────│                              │
         │                              │                              │

Human Provisioning (today: JIT provisioning) ​

Humans are provisioned on first authenticated API call by the ClerkAuth middleware in services/api/internal/core/middleware/auth.go. When a request arrives with a valid Clerk JWT but no matching humans.clerk_user_id row, provisionHuman():

  1. Fetches the user's profile (email, name) from the Clerk Backend API.
  2. In a single transaction, inserts a principals row (principal_type = 'human') and a humans row whose principal_id PK matches the freshly-minted principals.id. The human row carries clerk_user_id, email, confirmed = TRUE, blocked = FALSE.
  3. If the request carries an X-Organization-ID header (set by the Patient Portal's proxy.ts from the resolved subdomain), auto-links the new principal to that org as a patient by inserting a organization_memberships row.
  4. Audit-logs the human creation and (if applicable) the membership creation.

This single path covers every sign-in flow. There is no separate "register" call — the Clerk-side sign-up triggers the user to land on the protected layout, the layout makes its first call to /v1/me, and provisionHuman() runs.

Planned: Webhook-based Sync (not implemented) ​

Clerk supports webhooks for user lifecycle events (user.created, user.updated, user.deleted). When this lands (likely Layer 1.13), webhooks will close the gaps JIT can't:

EventActionWhy JIT alone is insufficient
user.created(No-op for our DB; JIT still owns provisioning)The request that creates a human is the same one that lands them in our DB; no benefit to a parallel webhook. Listed here only because Clerk emits it.
user.updatedUpdate humans.email (and any other synced fields)Email changes happen in Clerk's hosted account UI without hitting our API; without a webhook, our row would drift.
user.deletedSet humans.blocked = TRUE (never hard-delete — HIPAA + GDPR Art. 17(3)(c))A user deleted in Clerk can no longer authenticate, so JIT never re-runs to mark them blocked. Today the only fix is the manual SQL in Troubleshooting.

Implementation notes for when we build this:

  • Clerk uses Svix under the hood for webhook delivery (headers: Svix-Id, Svix-Timestamp, Svix-Signature).
  • Webhook signatures must be verified using CLERK_WEBHOOK_SECRET (env var to be added to internal/core/config/config.go at that time — does not exist today).
  • Use Svix-Id header + Redis SETNX for idempotency (72h TTL matches Clerk's retry window).
  • Endpoint: POST /webhooks/clerk — unauthenticated, signature-verified, sits beside /v1/public/* in routes.go.

Clerk Configuration ​

Environment Variables (today) ​

bash
# services/api/.env.local
CLERK_SECRET_KEY=sk_test_...         # required — token verification
# CLERK_WEBHOOK_SECRET — not used yet; do not set until the webhook endpoint exists
bash
# apps/clinic/.env.local, apps/portal/.env.local, apps/console/.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...        # same key as the API

Clerk Dashboard Setup ​

  1. Create application — sign up at https://clerk.com, create an app for each environment (dev / staging / prod).
  2. Get secret key — Dashboard → API Keys → Secret Key.
  3. Frontend setup — Dashboard → API Keys → Publishable Key. Add to each Next.js app's .env.local as NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY.
  4. Webhook configuration (planned, do not enable until the endpoint ships): Dashboard → Webhooks → Add Endpoint pointing at https://<api-host>/webhooks/clerk, subscribe to user.created, user.updated, user.deleted, copy the signing secret into CLERK_WEBHOOK_SECRET.

Session Token Lifetime ​

Clerk sessions are short-lived (default: 1 week) and automatically rotated. The frontend SDK handles token refresh transparently.

Token verification: Every API request verifies the token with Clerk via their SDK (cached for performance).

HIPAA Compliance ​

BAA Signing ​

Clerk provides a Business Associate Agreement (BAA) on the Enterprise plan. This is required for HIPAA compliance.

Action items:

  • [ ] Upgrade to Clerk Enterprise plan
  • [ ] Sign BAA via Clerk dashboard
  • [ ] Store signed BAA with compliance documentation

PHI in Clerk ​

Clerk stores:

  • Email address
  • Username (we use email)
  • Authentication metadata (IP, device, login history)

Not stored in Clerk:

  • Patient names, phone numbers, addresses
  • Medical data
  • Custom fields

All PHI lives in our database, not in Clerk.

Security Considerations ​

Token Verification ​

Every request to authenticated endpoints:

  1. Extracts Authorization: Bearer <token> header
  2. Verifies token with Clerk SDK
  3. Loads internal humans row (joined with the matching principals row) from DB (single query)
  4. Blocks if humans.blocked = true

Webhook Signature Verification ​

Webhooks are verified using HMAC signatures. Clerk signs every webhook with CLERK_WEBHOOK_SECRET. We verify before processing.

Security: Without signature verification, attackers could create fake webhooks to manipulate user data.

Failed Verifications ​

FailureResponseReason
Missing token401 UnauthorizedNo auth header
Invalid token401 UnauthorizedExpired or tampered
Human blocked403 Forbiddenhumans.blocked = TRUE (also audit-logged)
Invalid webhook signature401 UnauthorizedForged webhook (planned — only when the webhook endpoint ships)

JIT provisioning means a "human not found" path doesn't really exist for valid Clerk tokens — the rows get created during the same request. A failure inside provisionHuman (Clerk Backend API down, DB write fails) returns 500, not 401.

Troubleshooting ​

User exists in Clerk but not in our DB ​

Cause: The Clerk identity hasn't made its first authenticated API call yet — JIT provisioning runs on first call, not on Clerk-side sign-up.

Fix: Have the user load any authenticated app page (Clinic / Portal / Console). The page's first call to /v1/me triggers provisionHuman() and creates the rows.

If you genuinely need to backfill manually (e.g., for a test fixture), insert both the principal and the human in one transaction so the FK is satisfied:

sql
WITH new_principal AS (
    INSERT INTO principals (id, principal_type)
    VALUES (gen_random_uuid(), 'human')
    RETURNING id
)
INSERT INTO humans (principal_id, clerk_user_id, email, confirmed)
SELECT id, 'user_2abc...', 'user@example.com', TRUE FROM new_principal;

-- Optional: link to an org as a patient
INSERT INTO organization_memberships (principal_id, organization_id, role_id)
SELECT h.principal_id, o.id, r.id
FROM humans h, organizations o, roles r
WHERE h.clerk_user_id = 'user_2abc...'
  AND o.slug = 'demo'
  AND r.organization_id = o.id
  AND r.code = 'patient';

There is no humans.role or humans.username column — roles live on organization_memberships.role_id, and the email serves as the canonical identifier. See reference/rbac-permissions.md.

User deleted in Clerk but still active in our DB ​

Cause: No webhook sync today — Clerk-side deletes don't propagate automatically.

Fix: Manually block the human:

sql
UPDATE humans SET blocked = TRUE WHERE clerk_user_id = 'user_2abc...';

The next time anyone presents a token for that Clerk identity (which won't happen if Clerk truly deleted them, but covers the "disabled in Clerk" case), ClerkAuth returns 403 and audit-logs the failed request. This is the workaround until the webhook endpoint ships.

Testing ​

Manual User Creation (Testing Only) ​

For integration tests, bypass Clerk and seed humans directly via the test harness — see services/api/internal/test/rlstest/. The harness inserts principals + humans + organization_memberships rows against the AdminPool and stamps the RLS session variables (including app.current_principal_id and app.current_actor_type) to simulate authenticated requests; no Clerk round-trip is involved.

Never do this in production. All production humans must come from Clerk so the audit trail captures real authentication context.

Migration from Legacy System ​

If migrating from an existing auth system (e.g., Strapi):

  1. Export users from legacy system (email, password hash)
  2. Import to Clerk via Clerk API or bulk import
  3. Sync clerk_user_id back to our DB onto the matching humans row (after the paired principals row exists)
  4. Test login with migrated users

See ../../integrations/migration.md for full migration procedure.