Clerk dev instance
Scope: the configuration the shared Clerk dev instance needs before any auth-dependent testing works. Not how Clerk events reach your local DB — that's the webhook relay; not who you test as — that's test accounts.
Recorded 2026-09-13; re-verify live on next use. Dashboard state can't be read from code — the recorded values below were confirmed against the instance dashboard, not grep.
Which instance
- A Clerk dev instance, never production.
CLERK_SECRET_KEYinoptolink-backend/.envmust point at it. - The deployed test stack backend (optolink-test) runs on
a different Clerk dev instance than local
.env. Never cross-point backends, DBs, or portal publishable keys between the two — a portal pointed at one backend with the other's key fails auth in ways that look like bugs. - Instance app id:
app_3FUCVgyzycb2dhlYnQOTJwymdmz(fromclerk apps list). The backend (CLERK_SECRET_KEY) and the CLI (clerk whoami) must resolve to this same app — if a freshly-minted token 401s, check this first.
Dashboard configuration (one-time)
Enable Organizations
Dashboard → User & Authentication → Organizations → Enable. Required before seed/setup scripts can create orgs.
Enable Testing Mode
Dashboard → Configure → Testing → Enable. Makes OTP 424242 work for
+clerk_test email addresses (what the Playwright suite and manual signups
use).
Add org_metadata to the default session token
Dashboard → Sessions → "Customize session token" → add:
{ "org_metadata": "{{org_membership.public_metadata}}" }
ClerkAuthGuard reads payload['org_metadata']?.role for orgRole
(src/auth/guards/clerk-auth.guard.ts); RolesGuard compares it to the
route's @Roles() minimum. Without this claim every @Roles-gated endpoint
returns 403.
Three details that bite:
- The claim must be on the default session token, not a separate JWT
template — the portal calls
session.getToken()with no template argument (optolink-portal/src/components/clerk-token-bridge.tsx). - The shortcode uses snake_case
public_metadata(not camelCase). - The claim key is exactly
org_metadata— no dot.
Recorded instance state (configured via Clerk CLI):
session.claims = { "org_metadata": "{{org_membership.public_metadata}}" }.
Relax attack protection (dev only)
The shared test passwords get hammered by repeated automated sign-ins — relax the instance's password protections or sign-ins start failing on HiB/device checks:
clerk config patch --json '{
"auth_password": {
"enforce_hibp_on_sign_in": false,
"device_trust": { "enabled": false }
}
}'
⚠️ Dev instance only — never apply to production. Recorded 2026-09-13; re-verify live on next use (instance config, not source-checkable).
Testing quirks (recorded)
Everything below is CLI/API behavior — recorded 2026-08/09 against clerk CLI 3.0.0 + this dev instance; re-verify live on next use. None of it is source-checkable in this repo.
+clerk_testgoes in the local part:user+clerk_test@domain.comworks;user@domain+clerk_test.comis rejected. Requires Testing Mode (above).- Never create orgs in automated loops — the Clerk free tier caps
retained orgs (~100/month). Use stable seeded orgs; DB-only org rows (no
clerkOrgId) are fine for unit tests. - Never mix personas on one Clerk user. A user holding a second (ops) org
membership pins
o.idto that org at token mint — theorganization_idparam is ignored even for fresh sessions, and removing the membership doesn't unpin it. One user per persona (org owner ≠ ops staff). - Mint the token AFTER the membership exists. A session created before
the membership carries no
o.id— the raw/sessions/:id/tokensmint does not refresh the session's cached memberships. Create a fresh session post-membership (or let the portal'ssetActivepath touch the session). - Password users never see the
424242OTP shortcut — it applies to passwordless users only, so a password-bearing test user hits the password factor in browser tests. Set a known password via the Backend API (PATCH /users/:id, no CLI flag);clerk testing-tokensdoesn't exist in CLI v3. clerk users list --emaildoesn't exist — email lookup is a raw-API query param:GET /v1/users?email_address=….- Single-filter list endpoints return a bare array (no
{data}wrapper) —jq '.data[0]'parses null; usejq '.[0]'or handle both shapes. POST /v1/usersrejects JSON array values ("email_address":[…]→ 400request_body_invalid) — use form encoding (--data-urlencode) or JSON with plain-string values.- python-urllib calls get 403 while curl works — Clerk blocks the default
urllib User-Agent; add a
User-Agentheader (the backend's official SDK is unaffected). - The CLI mangles
POST /organizations/:id/memberships— returns an empty object (silent no-op; the ops org also has no defaultorg:memberrole). Use raw curl withrole: "org:admin".
Verify the instance is usable
Call any @Roles-gated portal endpoint with a real token: 200 means the
claim is present; 403 means the org_metadata claim is missing or the
local User/Organization/OrgMember rows don't exist yet (rows are created
only by the webhook handler — see the relay page).
Source-checked against optolink-backend @ 29f8589 and optolink-portal @ 7d31d45, 2026-10-06 (guard claim read,
RolesGuard403 behavior,OrgResolutionPiperow checks, portalgetToken()call). Dashboard state, app id, and the quirks list are recorded, not source-checked (quirks harvested 2026-10-06).