Skip to main content

WorkOS Production Configuration

Looking for the list of GitHub secrets? See the GitHub Secrets Inventory — single source of truth for which secrets exist, where they live, and which code reads them. This doc covers WorkOS-side config (orgs, OAuth clients, DNS); the inventory covers the secret names that flow into the deploy.

Context

This document captures the live configuration of the WorkOS production environment powering app.securityv0.com. Cutover from REQUIRE_AUTH=false dev bypass to real WorkOS prod authentication happened on 2026-04-30 (Phase B). This is an operational runbook — it describes what is, not why. The design rationale lives in 13 — Authentication and User Management; the architecture decisions are in ADR-016 and ADR-017.


⚠ Upcoming changes (per Auth Simplification Plan, 2026-05-08)

Several env vars described below are scheduled for removal or rename as part of the Auth Simplification Plan. Do not bake new automation against the current names without checking the plan's status.

Env varCurrent statePlanned changeStep
STAGING_PERSONAL_AGENT_IVAN_*, PROD_PERSONAL_AGENT_IVAN_* (3 each)Live; 6 secrets totalRemoved entirely. The personal-agent bridge is being deleted.S3
STAGING_WORKOS_APP_CLAUDECODE_*, PROD_WORKOS_APP_CLAUDECODE_* (2 each)Live; 4 secrets totalRenamed to unprefixed WORKOS_APP_CLAUDECODE_CLIENT_ID/SECRET. The GitHub Environment system already scopes per-env values; the prefix is dead weight.S1
WORKOS_REDIRECT_URI (legacy single-host scalar)Live alongside WORKOS_REDIRECT_URI_ALLOWED_HOSTSRemoved. Single host expressible as one-element allowlist.S4
WORKOS_COOKIE_PASSWORDLive alongside SESSION_COOKIE_PASSWORDRemoved; consolidated to SESSION_COOKIE_PASSWORD. The split exists for the dead OIDC path.S5
STAFF_SUPER_ADMIN_PROVIDER_USER_IDS (added by #816)Live; allowlist mechanism #3Resolution pending in S-1 — either deleted (if A or B is chosen) or becomes canonical with the other two deleted (if C is chosen).S-1
OIDC_*, JWT_*, ALLOWED_API_KEYS, API_KEY_HEADERListed in env.ts but unused at runtimeRemoved entirely. Dead code.S2

If you are operating prod and need to add a new auth env var: stop. Read Agent and M2M Authentication and §16 of architecture/13 first. The most likely answer is "use a path that already exists; do not add a fourth allowlist."

The current state below remains accurate for ops until each plan step lands. Each step's PR will update this section.


Prod environment overview

ItemValue
WorkOS environmentProduction (separate from staging)
AuthKit hosted UIhttps://powerful-falcon-83.authkit.app
AuthKit staging UI (for comparison)https://vast-balcony-35-staging.authkit.app
Custom OAuth domainauth.securityv0.com
Application frontendhttps://app.securityv0.com
OAuth callbackhttps://app.securityv0.com/auth/callback

Tip: Check the URL bar when debugging a redirect chain. powerful-falcon-83.authkit.app = prod; vast-balcony-35-staging.authkit.app = staging. ULID prefixes in client_id values (e.g., client_01KQ09N7...) are NOT environment indicators — confirm against the WorkOS dashboard.


WorkOS Organizations (prod)

Organizationorg_idPurpose
securityv0-internalorg_01KQE0B65RKR28CYAHHGEYKA1MSuper-admin org. Members get cross-tenant access per arch doc §6.
sv0-defaultorg_01KQE1M2YMVNH9Y7V1XQ4NW58DDefault tenant for new evaluation users.

Source of truth: WorkOS dashboard. The Mongo tenants collection mirrors the provider_org_id field. These org IDs are documented explicitly because Mongo seeding scripts and tenant-provisioning runbooks need them by value.

The tenants.provider_org_id field is unique — at most one Mongo tenants row can claim a given WorkOS org ID. The IDOR-class rationale, the tenant_slug cookie-mint escape hatch for staff super-admin, and the trigger conditions for a future bindings-table refactor are documented in 13-authentication-and-user-management.md §3.5.

Environment parity: staging WorkOS has independent org IDs for the same logical orgs. The staging counterparts live in the dev GitHub Environment as WORKOS_SUPER_ADMIN_ORG_ID (staff) and on dev.securityv0.com's tenants.default.provider_org_id (default-tenant binding). Look them up in the WorkOS dashboard's Staging environment when needed — they are intentionally not committed to this doc because they rotate on environment rebuilds.


DNS records (Cloudflare-managed)

RecordTypeTargetCloudflare record IDProxied
auth.securityv0.comCNAMEcname.workos-dns.com2b59204c9a86fd5deebf3325cdbefc37No (DNS-only)

⚠️ Never proxy auth.securityv0.com through Cloudflare. This breaks Google sign-in. The proxied: false (gray cloud, DNS-only) flag must remain set on this DNS record. WorkOS provisions and renews the TLS certificate for auth.securityv0.com directly. If Cloudflare proxying is enabled, Cloudflare terminates TLS and WorkOS can no longer provision or renew its cert — Google sign-in via the custom domain fails with a certificate error.

Verification

# CNAME resolves to WorkOS
dig auth.securityv0.com CNAME +short
# Expected: cname.workos-dns.com.

# TLS cert is issued by WorkOS, NOT Cloudflare
echo | openssl s_client -connect auth.securityv0.com:443 -servername auth.securityv0.com 2>/dev/null \
| openssl x509 -noout -issuer
# Expected: something from Let's Encrypt / WorkOS — NOT "O=Cloudflare, Inc."

Google OAuth client (GCP Console)

The WorkOS connection ID for Google OAuth is a67he6q7eyNlXD5NL0NrXEBjQ. This is a single WorkOS connection served under both auth.workos.com and auth.securityv0.com — it is not two connections. Both redirect URI variants must be present in the GCP OAuth client to keep both paths functional.

Redirect URIRequired forRemovable?
https://auth.workos.com/sso/oauth/google/a67he6q7eyNlXD5NL0NrXEBjQ/callbackOriginal WorkOS-hosted callback — fallback if custom domain breaksKeep — backup path
https://auth.securityv0.com/sso/oauth/google/a67he6q7eyNlXD5NL0NrXEBjQ/callbackCustom domain — Google consent screen shows auth.securityv0.com instead of auth.workos.comRequired — branded login UX
https://powerful-falcon-83.authkit.app/auth/google/callbackAuthKit hosted UI fallbackKeep — covers AuthKit-hosted flows
(staging redirect URIs)Staging environmentKeep — do not remove
(forward-looking custom domain URIs already added)Future tenant-specific domainsKeep — already provisioned

Removing the auth.workos.com URI is safe only if the custom domain is verified-and-active in WorkOS and no AuthKit flows rely on the hosted domain. We keep it as a fallback.


WorkOS auth methods enabled (prod)

Configure in WorkOS dashboard → Authentication → Methods (per environment — staging and prod are independent).

MethodStatusNotes
Magic AuthONDefault for evaluation users
Google OAuthONCustom domain: auth.securityv0.com
PasswordOFFWe do not manage passwords
SAML SSOPer-customerEnabled per org via Admin Portal on contract sign — not a global toggle

Warning: A stock WorkOS environment starts with no methods enabled, showing only the "Continue with SSO" option. If you provision a new environment or restore from backup, re-enable Magic Auth and Google OAuth manually before testing.


Super-admin provisioning and JIT policy

Two separate mechanisms control access for SecurityV0 staff and customer-prospect users; do not confuse them.

Super-admin (SecurityV0 staff)

Super-admin status is derived in src/api/routes/auth.ts at callback time (NOT in the membership middleware). The precedence is:

  1. If the auth provider explicitly set result.isSuperAdmin (e.g. CF Access JWT path; also the DevAuthProvider for local) → use it.
  2. Else if WORKOS_SUPER_ADMIN_ORG_ID env var is set → super-admin iff the user's providerOrgId matches that org. This is the prod path.
  3. Else (env var unset — dev/test fallback only) → super-admin iff email ends with @securityv0.com.

For prod to grant super-admin correctly:

  • WORKOS_SUPER_ADMIN_ORG_ID must be set in the prod GitHub Environment to org_01KQE0B65RKR28CYAHHGEYKA1M (the securityv0-internal prod org).
  • Each SecurityV0 staff member must be a member of securityv0-internal in the WorkOS dashboard. A bare @securityv0.com Google sign-in is NOT enough on its own — without WorkOS membership, the user authenticates but lands on the "No workspace access" page.
  • Verify both: gh secret list --env prod --repo SecurityV0/sv0-platform | grep SUPER_ADMIN_ORG_ID, plus dashboard → Production → Organizations → securityv0-internal → Members.

Customer-prospect JIT (verified-domain auto-join)

A separate JIT mechanism in src/api/middleware/auth-middleware.ts (membership middleware) auto-creates a member-role membership when a logged-in user's email domain matches a tenant's verified_domains list. This:

  • Only applies to tenant-scoped routes (/api/v1/tenants/:slug/...), not at callback time.
  • Creates member role only — never super-admin.
  • Requires the tenant to have the email's domain explicitly added to verified_domains in Mongo.

Customer prospects without a verified-domain match require explicit invitation via the WorkOS Admin Portal or the scripts/provision-eval-tenant.ts script.


ItemValue / location
Cookie namesv0_session
Cookie flagsHttpOnly, Secure, SameSite=Lax
Session libraryiron-session
Env variableWORKOS_COOKIE_PASSWORD
Secret length64-char hex (32 bytes)
Last rotated2026-04-30
1Password locationsecurityv0-prod / WorkOS / cookie-password

Rotation procedure

Iron-session cookies are encrypted (sealed with iron-session's AES-256 + HMAC-SHA256 scheme) using WORKOS_COOKIE_PASSWORD. Rotating this value invalidates all active sessions — every existing cookie fails to decrypt and every logged-in user must re-authenticate.

  1. Generate a new 32-byte secret: openssl rand -hex 32
  2. Update the GitHub production environment variable WORKOS_COOKIE_PASSWORD.
  3. Update the 1Password entry at securityv0-prod / WorkOS / cookie-password.
  4. Redeploy the production container (picks up the new env var).
  5. Verify login works end-to-end before closing the change window.
  6. Update the "Last rotated" date in this runbook so future rotations have an accurate baseline.

Plan rotation during a low-traffic window (e.g., off-hours weekday evening).


Common operational gotchas (hit during cutover)

"Continue with SSO" only — no Magic Auth or Google buttons

Cause: WorkOS auth methods are not enabled in the environment. A stock environment shows only the SSO option.

Fix: WorkOS dashboard → Authentication → Methods → enable Magic Auth and Google OAuth. Repeat per environment — staging and prod are independent.


redirect_uri_invalid after deploy

Cause: WorkOS rejects the OAuth callback URI because it is not in the app's allowed redirect list.

Fix: WorkOS dashboard → Configuration → Redirects → add the URI. This is at the WorkOS app level, not per-organization. Also add the URI to the GCP OAuth client if it is a new custom-domain variant.


ULID prefixes do not identify the environment

Cause: client_id like client_01KQ09N7... looks like a staging ID but could be prod.

Fix: Always confirm which environment an ID belongs to by looking it up in the WorkOS dashboard. Never infer environment from the ULID prefix.


AuthKit subdomain tells you the environment

Subdomain patternEnvironment
<name>.authkit.app (no -staging suffix)Production
<name>-staging.authkit.appStaging

When debugging a redirect chain, check the URL bar. Our prod AuthKit URL is powerful-falcon-83.authkit.app; staging is vast-balcony-35-staging.authkit.app.


Custom domain CNAME requires WorkOS verification before redirect URIs work

After creating the DNS CNAME record for auth.securityv0.com, the custom domain must be explicitly verified in WorkOS before redirect URIs using it will be accepted:

  1. WorkOS dashboard → Authentication → Custom Domain → click Verify DNS
  2. After verification passes → click Test Google Redirect URI
  3. Click Save

Skipping any step leaves the custom domain in a half-configured state where the CNAME resolves but WorkOS rejects redirect URIs pointing to it.


Verification commands

Copy-paste shell to confirm prod auth is healthy:

# DNS — CNAME points to WorkOS
dig auth.securityv0.com CNAME +short
# Expected: cname.workos-dns.com.

# TLS — cert is from WorkOS (not Cloudflare)
echo | openssl s_client -connect auth.securityv0.com:443 -servername auth.securityv0.com 2>/dev/null \
| openssl x509 -noout -issuer
# Expected: issuer from Let's Encrypt or WorkOS CA — NOT Cloudflare, Inc.

# AuthKit hosted UI is reachable
curl -sI https://powerful-falcon-83.authkit.app/ | head -1
# Expected: HTTP/2 307 (redirects to api.workos.com/user_management/initiate_login).
# A 200 indicates AuthKit is in an unexpected state; 4xx/5xx means WorkOS is down.

# Custom domain is NOT proxied through OUR Cloudflare zone.
#
# Header-based detection (cf-ray, `server: cloudflare`) does NOT work here:
# WorkOS itself fronts `cname.workos-dns.com` with Cloudflare, so those
# headers appear on `auth.securityv0.com` regardless of OUR proxied flag.
# Use one of the two checks below instead.
#
# Check 1 (fastest, no API token): the TLS issuer above already catches a
# wrong-direction proxied state — if our zone proxied this record, our edge
# would terminate TLS and the issuer would be Cloudflare, Inc. The Let's
# Encrypt / WorkOS CA result confirms WorkOS is the TLS terminator, which
# is only true when `proxied: false`.
#
# Check 2 (definitive): query the Cloudflare DNS API for the proxied flag
# directly. This requires a token with **Zone:Read** scope on the
# `securityv0.com` zone, which is NOT the same as the Pages-deploy
# `CLOUDFLARE_API_TOKEN` documented in `ci-cd-operations.md`. Either reuse
# the Terraform infra token (`CF_API_TOKEN_TERRAFORM_SV0_INFRA`) or mint a
# fresh Zone:Read token in the Cloudflare dashboard.
#
# ZONE_TOKEN="<Zone:Read scoped token>" # NOT CLOUDFLARE_API_TOKEN
# SV0_ZONE_ID="$(curl -sS -H "Authorization: Bearer $ZONE_TOKEN" \
# 'https://api.cloudflare.com/client/v4/zones?name=securityv0.com' \
# | jq -r '.result[0].id')"
# curl -sS -H "Authorization: Bearer $ZONE_TOKEN" \
# "https://api.cloudflare.com/client/v4/zones/$SV0_ZONE_ID/dns_records?name=auth.securityv0.com" \
# | jq '.result[] | {name, type, content, proxied}'
# Expected: { "name": "auth.securityv0.com", "type": "CNAME",
# "content": "cname.workos-dns.com", "proxied": false }
# If "proxied": true → flip to false in the Cloudflare dashboard or via the API.

# Visual confirmation (always available): Cloudflare dashboard → DNS →
# `auth.securityv0.com` row → cloud icon must be GRAY (DNS only), not orange.

# App login redirects to AuthKit
# NOTE: app.securityv0.com is also gated by Cloudflare Access. Without a CF service-token
# header pair, this curl returns the CF Access redirect, not the WorkOS redirect.
# To skip CF Access, supply the service token (see cf-access-service-token-setup.md):
# curl -sI https://app.securityv0.com/login \
# -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
# -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
# | grep -i location
# Expected with token: Location: https://powerful-falcon-83.authkit.app/...

Out of scope — what this doc does NOT cover

TopicWhere to look
Architecture and design rationale13 — Authentication and User Management
Provider selection rationaleADR-017
Webhook receiver implementationDeferred — not yet wired
SCIM Directory SyncPer-customer, not yet enabled
SAML connections per customerCreated via Admin Portal on contract sign
Branding the hosted login page (colors, logo, font)WorkOS AuthKit branding (in the brand spec, not here — this is design application, not auth config)