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/(aVerifierimplementation), the principal lookup ishuman.Resolverininternal/core/domain/human/, andhumans.clerk_user_idis now the provider-agnostichumans.provider_subject_id. Clerk is the only verifier today, but the rest of the platform sees onlyauth.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 bymiddleware.Authenticate): the first authenticated API call from a Clerk identity creates the internalprincipals+humansrows in a single transaction by fetching the profile from the Clerk Backend API. There is noPOST /webhooks/clerkendpoint wired inservices/api/internal/core/server/routes.go, noSvix-*signature verification path, noCLERK_WEBHOOK_SECRETconsumer ininternal/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():
- Fetches the user's profile (email, name) from the Clerk Backend API.
- In a single transaction, inserts a
principalsrow (principal_type = 'human') and ahumansrow whoseprincipal_idPK matches the freshly-mintedprincipals.id. The human row carriesclerk_user_id,email,confirmed = TRUE,blocked = FALSE. - If the request carries an
X-Organization-IDheader (set by the Patient Portal'sproxy.tsfrom the resolved subdomain), auto-links the new principal to that org as apatientby inserting aorganization_membershipsrow. - 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:
| Event | Action | Why 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.updated | Update 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.deleted | Set 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 tointernal/core/config/config.goat that time — does not exist today). - Use
Svix-Idheader + RedisSETNXfor idempotency (72h TTL matches Clerk's retry window). - Endpoint:
POST /webhooks/clerk— unauthenticated, signature-verified, sits beside/v1/public/*inroutes.go.
Clerk Configuration
Environment Variables (today)
# 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# 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 APIClerk Dashboard Setup
- Create application — sign up at https://clerk.com, create an app for each environment (dev / staging / prod).
- Get secret key — Dashboard → API Keys → Secret Key.
- Frontend setup — Dashboard → API Keys → Publishable Key. Add to each Next.js app's
.env.localasNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. - Webhook configuration (planned, do not enable until the endpoint ships): Dashboard → Webhooks → Add Endpoint pointing at
https://<api-host>/webhooks/clerk, subscribe touser.created,user.updated,user.deleted, copy the signing secret intoCLERK_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:
- Extracts
Authorization: Bearer <token>header - Verifies token with Clerk SDK
- Loads internal
humansrow (joined with the matchingprincipalsrow) from DB (single query) - 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
| Failure | Response | Reason |
|---|---|---|
| Missing token | 401 Unauthorized | No auth header |
| Invalid token | 401 Unauthorized | Expired or tampered |
| Human blocked | 403 Forbidden | humans.blocked = TRUE (also audit-logged) |
| Invalid webhook signature | 401 Unauthorized | Forged 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:
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:
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):
- Export users from legacy system (email, password hash)
- Import to Clerk via Clerk API or bulk import
- Sync clerk_user_id back to our DB onto the matching
humansrow (after the pairedprincipalsrow exists) - Test login with migrated users
See ../../integrations/migration.md for full migration procedure.
Related Documentation
- API Endpoints —
/v1/me,/v1/me/switch-organization(today's surface)./webhooks/clerkis planned, not wired. - Session Management — RLS setup, middleware stack
- Organizations — Organization context, multi-tenancy