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 (Core 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 Core 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...', '[email protected]', 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.