ConceptsAuth model

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.

CallerExampleAuth pattern
User via appA patient using loop-health to view biomarkersOAuth 2.1 authorization_code + PKCE
Service via serviceJobs cron calling membership to release commission locksM2M (client_credentials)
Internal staffAn ops engineer hitting an admin endpointOAuth 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=S256 only — plain is 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_token is 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 like read: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:

  1. Reads the Authorization: Bearer <token> header.
  2. Calls services/identity/v1/oauth/introspect (cached in Redis for the token’s TTL).
  3. Compares the token’s scopes against the required scope(s).
  4. 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 in WWW-Authenticate)

The scope-satisfaction rules (from @platform/scopes):

  • Direct match: token has read:biomarkers, route requires read:biomarkers → ✓
  • Admin pass-through: token has admin:clinical, route requires read:biomarkers → ✓ (admin scopes cover all user scopes in their domain)
  • Manage → read: token has manage:subscriptions, route requires read:subscriptions → ✓

Who is WorkOS for, who is Clerk for?

This is the most-asked question.

  • WorkOS — the SSO + directory layer behind services/identity for 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/admin and apps/developer-portal use 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 by client_id / client_secret or 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

WhereErrorMeaningWhat to do
/authorizeinvalid_clientUnknown client_id or wrong redirect_uriCheck developer portal registration
/authorizeinvalid_scopeRequested a scope that doesn’t exist or requires BAASee Scopes
/tokeninvalid_grantCode expired, used, or PKCE mismatchRestart the flow
/tokeninvalid_clientWrong client_secretRotate secret
Any serviceinvalid_tokenAccess token expired, revoked, or malformedRefresh and retry once
Any serviceinsufficient_scopeRequired scope not in tokenRe-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 of SESSION_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/resolve is the bridge.
  • SDK calls reuse the WorkOS access token. When useResource* hooks in @platform/admin-kit fetch from a service, they send the staff token as a bearer.

Required SST secrets per stage:

SecretValue source
PLATFORM_ADMIN_WORKOS_CLIENT_IDWorkOS dashboard → Applications → your app → Client ID
PLATFORM_ADMIN_WORKOS_API_KEYWorkOS dashboard → API Keys (matching environment!)
PLATFORM_ADMIN_SESSION_COOKIE_SECRETopenssl rand -base64 32
WORKOS_API_KEY (services/identity)Same WorkOS environment as platform-admin

Setup runbook: WorkOS + platform-admin setup.

Source ADRs

ADR-0008 (WorkOS adoption), ADR-0036 (M2M client_credentials), ADR-0048 (secrets management), ADR-0052 (Connect / OAuth platform).