Skip to main content

ADR-0020: Production identity for the Account portal (Clerk)

Status: Accepted · 2026-07-15

Context

ADR-0017 shipped the Account portal with a dev (email-only) auth mode and an idp mode that returned 501 pending an external identity provider — production was blocked on a real IdP. This ADR implements that IdP with Clerk.

Decisions

1. Clerk, verified locally against its public JWKS — no secret key

The portal accepts a Clerk session token (a short-lived RS256 JWT the Clerk frontend mints) and verifies it locally (account/idp-clerk.ts): RS256 signature by kid against Clerk’s public JWKS, then iss = configured issuer, exp/nbf temporal validity, and azp ∈ configured authorized parties. Only Clerk’s public keys are used — no Clerk secret key is held, logged, or committed, so there is no high-value credential in the vendor plane. The JWKS is fetched + cached (10 min); a fetch failure fails closed (jwks_unavailable). Why Clerk over the alternatives, for this portal specifically:
  • Clerk — best developer experience for a modern web portal, drop-in hosted sign-in, MFA/passkeys/social + SAML enterprise connections, generous free tier. Chosen (the operator already uses it).
  • WorkOS — strongest if the priority is reselling enterprise SSO/SAML/SCIM; more B2B-plumbing than end-user auth UI.
  • Microsoft Entra External ID — natural adjacency (Find My Data customers are M365/Entra shops), but heavier to stand up for the commercial portal.
  • Auth0 — mature but pricier at scale. Clerk covers the enterprise-SSO case too (SAML connections), so it doesn’t foreclose the Entra/WorkOS path later.

2. JIT provisioning by verified email; our own session after

On a verified token the portal just-in-time provisions the portal_user by email (reuse if present, else create) and then issues its own opaque server-side session (the same session model as dev mode) — so the rest of the portal (CSRF, membership guards, org views) is unchanged and IdP-agnostic. The email must be present + well-formed in the token; the operator adds "email": "{{user.primary_email_address}}" to Clerk’s session-token customization (a public setting). A token without a verifiable email is rejected — we never guess an identity from sub alone.

3. Fail-safe mode selection + config guards

idp mode requires FMD_CLERK_ISSUER (https) + FMD_CLERK_PUBLISHABLE_KEY (both public); the JWKS URL derives from the issuer, and authorized parties parse from a comma list. Production still refuses dev auth mode. In idp mode the dev self-service register + email session routes return 501, and /session/idp is the only sign-in path; in dev mode /session/idp returns 501. The publishable key (public) is exposed via /health so the UI can initialize Clerk.

4. UI loads Clerk only in idp mode

The portal UI reads authMode from /health; in idp mode it loads Clerk.js (from the Clerk CDN derived from the publishable key), presents “Sign in with your organization,” and on success exchanges the Clerk session token at /session/idp. In dev mode it shows the email forms and loads no external script. (A production deployment behind a CSP must allow the Clerk frontend origin.)

Consequences

  • Production sign-in for the Account portal is implemented and testable; the operator supplies the public Clerk config to activate it.
  • The verifier is the whole trust boundary — hence it pins RS256 (rejects alg:none/HS256), checks the signature before any claim, and is covered by forgery/tamper/expiry/azp/alg tests + an adversarial review.
  • Out of scope (future): Clerk Organizations → portal org mapping (today an IdP user JIT-creates a personal portal user who then creates/joins orgs via the existing flow), SCIM deprovisioning, and step-up auth for sensitive actions.

Verification

account/idp-clerk.test.ts (12) — valid-token accept + email normalization; wrong-issuer / expired / not-yet-valid; unauthorized azp; missing email; cross-key forgery + unknown kid; tampered payload; alg:none rejected; JWKS fetch failure fails closed; the /session/idp endpoint (JIT provisioning + real session, 401 on bad token, 501 outside idp mode, health advertises the public key); and the config guards. All 290 server tests pass; typecheck + lint clean; adversarially reviewed.