ADR-0020: Production identity for the Account portal (Clerk)
Status: Accepted · 2026-07-15Context
ADR-0017 shipped the Account portal with adev (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 theportal_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 readsauthMode 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.