Auth model
What this is: how every kind of caller authenticates and authorizes against the platform.
Who it’s for: anyone building against a platform service, anyone debugging a 401 / 403, anyone writing a new service that needs to enforce auth.
What to read next: Connect with Loop (the user-facing OAuth spec), Scopes, Identity service.
The three callers
Every request to a platform service is one of three things. Knowing which one tells you which flow applies.
| Caller | Example | Auth pattern |
|---|---|---|
| User via app | A patient using loop-health to view biomarkers | OAuth 2.1 authorization_code + PKCE |
| Service via service | Jobs cron calling membership to release commission locks | M2M (client_credentials) |
| Internal staff | An ops engineer hitting an admin endpoint | OAuth user-flow with admin-scoped role |
These three reduce to two flows: user-OAuth and M2M. The “internal staff” case is just user-OAuth where the user happens to have admin role assignments.
The complete auth picture
User-OAuth (authorization_code + PKCE)
Used by: loop-health, apps/admin, the developer portal, the iOS/Android apps when they exist, and every third-party app.
The flow ships in services/identity and is documented in detail at Authorization flow. The short version:
Key properties:
- PKCE is mandatory for every client, confidential or public.
code_challenge_method=S256only —plainis rejected. - Access tokens are opaque (
lph_at_*), not JWTs. Services validate via introspection so revocation is instant. - Refresh tokens rotate on every use. Reuse of an old refresh token triggers grant-wide revocation and a security alert.
id_tokenis a signed JWT for proving identity to the client app — but it’s NOT what services use for authorization. Services only trust the access token.
M2M (client_credentials)
Used by: jobs / cron, internal service-to-service traffic, any system that has no user behind it.
Key differences from user-OAuth:
- No user, no consent, no refresh token.
- Tokens are short-lived (5–15 min) and never refreshed — caller just gets a new one when needed.
- Scopes are admin-only:
admin:clinical,admin:payments, etc. M2M tokens never carry user-facing scopes likeread:biomarkers. - M2M clients are registered by the platform team (not via the developer portal).
Scope enforcement
Every route in every service calls requireScope(...). This is enforced by the require-scope-enforcement convention check — a PR that adds a route without a scope is rejected at CI.
import { SCOPES } from "@platform/scopes";
app.use("/v1/biomarkers/*", requireScope(SCOPES.READ_BIOMARKERS, SCOPES.ADMIN_CLINICAL));What requireScope does:
- Reads the
Authorization: Bearer <token>header. - Calls
services/identity/v1/oauth/introspect(cached in Redis for the token’s TTL). - Compares the token’s
scopesagainst the required scope(s). - Allows the request if any required scope is present. Otherwise:
- No token → 401
invalid_token - Token without required scope → 403
insufficient_scope(with the scope name inWWW-Authenticate)
- No token → 401
The scope-satisfaction rules (from @platform/scopes):
- Direct match: token has
read:biomarkers, route requiresread:biomarkers→ ✓ - Admin pass-through: token has
admin:clinical, route requiresread:biomarkers→ ✓ (admin scopes cover all user scopes in their domain) - Manage → read: token has
manage:subscriptions, route requiresread:subscriptions→ ✓
Who is WorkOS for, who is Clerk for?
This is the most-asked question.
- WorkOS — the SSO + directory layer behind
services/identityfor users with corporate identity (staff, partner-employees with their own IdP). When you sign in via “Connect with Loop”, you may be redirected to WorkOS, which then federates to Google / Okta / etc. - Clerk — the sign-in UI + session layer for consumer-facing apps where there’s no enterprise IdP.
apps/adminandapps/developer-portaluse Clerk because their users are individual staff with Loop accounts (not federated).
Both feed into services/identity. Identity is the source of truth for “who is this user?” — neither Clerk nor WorkOS is consulted by services directly. The user ID in an access token is the platform user ID, mapped from the IdP via identity.user_links.
What never authenticates
- Health checks (
/healthz,/readyz) — public, no auth, no logging beyond standard request log. - OIDC discovery (
/.well-known/openid-configuration,/.well-known/jwks.json) — public. - Authorize endpoint (
/v1/oauth/authorize) — public entry point, but requires sign-in to complete. - Token endpoint (
/v1/oauth/token) — authenticates byclient_id/client_secretor PKCE, not by access token. - Revoke + introspect endpoints — authenticate by client credentials.
These are listed in each service’s publicPaths array in index.ts. The health-public convention check enforces that healthz is never accidentally gated behind auth.
Errors
| Where | Error | Meaning | What to do |
|---|---|---|---|
| /authorize | invalid_client | Unknown client_id or wrong redirect_uri | Check developer portal registration |
| /authorize | invalid_scope | Requested a scope that doesn’t exist or requires BAA | See Scopes |
| /token | invalid_grant | Code expired, used, or PKCE mismatch | Restart the flow |
| /token | invalid_client | Wrong client_secret | Rotate secret |
| Any service | invalid_token | Access token expired, revoked, or malformed | Refresh and retry once |
| Any service | insufficient_scope | Required scope not in token | Re-authorize with the missing scope |
Full error reference: Errors.
Platform-admin staff sign-in
The internal apps/platform-admin app (and any other Loop admin) doesn’t use the OAuth authorization flow described above. It uses a simpler staff-session flow against WorkOS AuthKit directly:
Key points:
- HttpOnly + signed cookie. Session is
Secure,SameSite=Lax, signed with HMAC ofSESSION_COOKIE_SECRET. No tokens in localStorage or URLs. - WorkOS does the auth method. Magic Auth (email code), SSO (SAML/OIDC), and passkeys are all enabled per environment in the WorkOS dashboard.
- services/identity owns the staff mapping. WorkOS knows who you are; services/identity knows what you can do on Loop.
POST /v1/admin/staff/resolveis the bridge. - SDK calls reuse the WorkOS access token. When
useResource*hooks in@platform/admin-kitfetch from a service, they send the staff token as a bearer.
Required SST secrets per stage:
| Secret | Value source |
|---|---|
PLATFORM_ADMIN_WORKOS_CLIENT_ID | WorkOS dashboard → Applications → your app → Client ID |
PLATFORM_ADMIN_WORKOS_API_KEY | WorkOS dashboard → API Keys (matching environment!) |
PLATFORM_ADMIN_SESSION_COOKIE_SECRET | openssl rand -base64 32 |
WORKOS_API_KEY (services/identity) | Same WorkOS environment as platform-admin |
Setup runbook: WorkOS + platform-admin setup.
Related
- Connect with Loop — the third-party-facing OAuth spec
- Authorization flow — step-by-step PKCE
- Tokens — access, refresh, id_token shapes + lifetimes
- Scopes — the full scope taxonomy
- Security — PKCE rules, redirect URIs, BAAs
- System overview
- services/identity — the implementation
- Admin kit — how staff sessions surface in admin UI
Source ADRs
ADR-0008 (WorkOS adoption), ADR-0036 (M2M client_credentials), ADR-0048 (secrets management), ADR-0052 (Connect / OAuth platform).