Skip to main content

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_KEY in optolink-backend/.env must 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 (from clerk 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_test goes in the local part: user+clerk_test@domain.com works; user@domain+clerk_test.com is 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.id to that org at token mint — the organization_id param 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/tokens mint does not refresh the session's cached memberships. Create a fresh session post-membership (or let the portal's setActive path touch the session).
  • Password users never see the 424242 OTP 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-tokens doesn't exist in CLI v3.
  • clerk users list --email doesn'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; use jq '.[0]' or handle both shapes.
  • POST /v1/users rejects JSON array values ("email_address":[…] → 400 request_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-Agent header (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 default org:member role). Use raw curl with role: "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, RolesGuard 403 behavior, OrgResolutionPipe row checks, portal getToken() call). Dashboard state, app id, and the quirks list are recorded, not source-checked (quirks harvested 2026-10-06).