WorkOS Auth Pattern
WorkOS is a B2B auth platform used by many SaaS and desktop apps. It started as an enterprise SSO product (SAML, SCIM) but added WorkOS User Management in 2023 — a full-stack auth system covering consumer sign-up, social login, and enterprise SSO in one package.
Part of Layer 3: Auth & Runtime. See also Electron deep dive for how WorkOS tokens are stored in Electron apps.
Recognizing WorkOS
Section titled “Recognizing WorkOS”The JWT iss (issuer) claim will contain workos or point to a custom auth
domain backed by WorkOS:
{ "iss": "https://auth.granola.ai/user_management/client_01JZJ0X...", "workos_id": "user_01K2JVZM...", "external_id": "c3b1fa46-...", "sid": "session_01KH4JGG...", "sign_in_method": "CrossAppAuth"}Key claims:
| Claim | Meaning |
|---|---|
workos_id | WorkOS-native user ID (user_01...) |
external_id | Previous auth provider’s user UUID (preserved on migration) |
sid | WorkOS session ID (session_01...) |
sign_in_method | How the session was created: SSO, Password, GoogleOAuth, CrossAppAuth |
iss | Contains /user_management/client_<id> for WorkOS User Management |
Token File Shape
Section titled “Token File Shape”Apps that store WorkOS tokens locally typically use one of these shapes:
Post-migration (Supabase → WorkOS)
Section titled “Post-migration (Supabase → WorkOS)”{ "workos_tokens": "{\"access_token\":\"eyJ...\",\"refresh_token\":\"...\",\"expires_in\":21599,\"obtained_at\":1234567890,\"session_id\":\"session_01...\",\"external_id\":\"uuid\",\"sign_in_method\":\"CrossAppAuth\"}", "session_id": "session_01...", "user_info": "{\"id\":\"uuid\",\"email\":\"...\"}"}Note: workos_tokens is a JSON string (double-encoded), not an object.
import json
with open("supabase.json") as f: raw = json.load(f)
tokens = json.loads(raw["workos_tokens"]) # parse the inner stringaccess_token = tokens["access_token"]refresh_token = tokens["refresh_token"]expires_in = tokens["expires_in"] # secondsobtained_at = tokens["obtained_at"] # ms epochNative WorkOS storage
Section titled “Native WorkOS storage”Some apps store tokens more directly:
{ "access_token": "eyJ...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 3600}Token Lifecycle
Section titled “Token Lifecycle”WorkOS access tokens are short-lived (typically 6 hours / 21600s).
import time, json, base64
def is_expired(token: str, buffer_s: int = 300) -> bool: payload = token.split('.')[1] payload += '=' * (4 - len(payload) % 4) claims = json.loads(base64.urlsafe_b64decode(payload)) return claims['exp'] < time.time() + buffer_s
def get_token(token_file: str) -> str: with open(token_file) as f: raw = json.load(f) tokens = json.loads(raw.get("workos_tokens", "{}")) or raw access = tokens["access_token"] if is_expired(access): # Option A: open the app to refresh # Option B: call the WorkOS refresh endpoint directly raise ValueError("Token expired — open the app to refresh") return accessRefreshing without the app
Section titled “Refreshing without the app”If you have the refresh_token and client_id, you can refresh directly:
import httpx, json
def refresh_workos_token(refresh_token: str, client_id: str, auth_domain: str) -> dict: """auth_domain e.g. 'https://auth.granola.ai'""" resp = httpx.post(f"{auth_domain}/user_management/authenticate", json={ "client_id": client_id, "grant_type": "refresh_token", "refresh_token": refresh_token, }) resp.raise_for_status() return resp.json()The client_id is embedded in the iss claim:
https://auth.example.com/user_management/client_01JZJ0X... →
client_01JZJ0X...
Calling the API
Section titled “Calling the API”WorkOS-protected APIs expect a standard Bearer token plus usually some app-specific identity headers. Always check the bundle for custom headers before assuming a 401 is a token problem:
import json, httpxfrom pathlib import Path
TOKEN_FILE = Path.home() / "Library/Application Support/AppName/supabase.json"
def get_headers() -> dict: with open(TOKEN_FILE) as f: raw = json.load(f) tokens = json.loads(raw["workos_tokens"]) return { "Authorization": f"Bearer {tokens['access_token']}", "X-Client-Version": "1.0.0", # from app package.json "X-Client-Platform": "darwin", # Add X-Workspace-Id, X-Device-Id etc. if the app sends them }
with httpx.Client(http2=True) as client: resp = client.post("https://api.example.com/v1/some-endpoint", json={"param": "value"}, headers=get_headers()) print(resp.json())Supabase → WorkOS Migration
Section titled “Supabase → WorkOS Migration”Many companies migrated from Supabase Auth to WorkOS. Signs you’ve hit a migrated app:
- Token file named
supabase.jsonbut containsworkos_tokenskey - JWT has both
workos_idandexternal_id(the old Supabase UUID) isspoints to a custom domain (notsupabase.co)- Database tables still use the old Supabase UUID as primary key
The migration preserves the old UUID as external_id precisely so FK
constraints don’t need to be updated.
Why migrate? Supabase Auth is great for consumer apps; WorkOS adds enterprise SSO (SAML/OIDC), SCIM directory sync, and an admin portal. B2B SaaS companies migrate when enterprise customers demand SSO.
Common migration path:
Supabase Auth → WorkOS User Management → (optionally) full WorkOS SSOCompetitors in this space: Clerk (more consumer/Next.js focused), Auth0 (enterprise, heavyweight), Stytch (developer-first).