Authentication
Seven transports, each with one job. Picking the wrong one is the most common integration mistake, so this page starts with the table and then explains when each applies.
| Mode | Transport | Used for |
|---|---|---|
| Public | no credential | Signup, login initiation and verification, availability probes, provider callbacks |
| IAM bearer | Authorization: Bearer … |
Carbon or Silicon API access |
| Browser session | secure iam_session cookie |
Interactive application login and SSO navigation |
| Application | HTTP Basic, app ID and app secret | Token exchange, introspection, revocation, and OBO |
| Platform admin | IAM bearer whose Carbon holds a current grant | /api/v1/admin/* |
| Step-up | X-Step-Up-Token, in addition to a bearer |
Ownership, credentials, SSO, deletion, privileged grants |
| WorkOS | verified WorkOS-Signature |
The WorkOS webhook receiver |
Credential lifetimes
These are exact. Startup rejects a deployment that overrides any of them.
| Credential or state | Lifetime |
|---|---|
| Signup session | 48 hours |
| Email or phone OTP | 10 minutes |
| IAM and application access token | 30 minutes |
| IAM session refresh family (Carbon or Silicon) | 900 days, absolute |
| Application OAuth refresh family | 900 days, absolute |
| Short-lived login token | 2 minutes, single use |
| Step-up token | 5 minutes, bound to one action on one resource |
| Carbon invitation | 48 hours |
| WorkOS setup link | 5 minutes |
| OBO proof | 60 seconds maximum, single use |
| One-time secret replay envelope | 10 minutes |
Refresh tokens rotate, and reuse is fatal
The family boundary matters. An IAM refresh family belongs to one Carbon device session or one Silicon session; compromise invalidates that session and authority descended from it, not the principal's other device sessions. An OAuth refresh family belongs to one parent IAM session and one client Application; compromise revokes that Application family and its access tokens without revoking the parent IAM session or another Application's tokens.
This is deliberate: it is what makes a stolen refresh token detectable. It also means a client
must serialise refresh per family. Two browser tabs or worker replicas starting separate refresh
operations with the same token will compromise that family. An uncertain transport retry is safe
only when it repeats the identical request with the original Idempotency-Key.
Concretely, a correct client:
- refreshes at most once at a time for each family, across every concurrent caller;
- waits for an in-flight refresh rather than starting a second;
- persists the replacement token before allowing another refresh;
- reuses the original idempotency key for an exact transport retry; and
- treats a reuse rejection —
unauthenticatedfor IAM orinvalid_grantfor OAuth — as terminal for the affected family and re-authenticates.
Access and refresh tokens are opaque 256-bit random values. There is no signing-key discovery surface and nothing to parse: when an application needs to know whether a token is still good right now, it calls introspection.
Carbon login
Passwordless, in two calls. POST /api/v1/login/challenges takes exactly one of
email, phone_number or carbon_id;
POST /api/v1/login/challenges/{session_id}/verify exchanges the six-digit code for
a credential pair.
A carbon_id challenge dispatches a code to both verified channels,
and either one satisfies the verification. Say so in your interface — a user who checks only
their inbox will otherwise sit waiting for an SMS that already arrived.
Email codes are generated and digest-verified by IAM. Phone codes are generated, routed and validated by Twilio Verify; IAM stores only the provider attempt identifier and its own challenge lifecycle state. Phone numbers must be supplied in E.164 form.
Login initiation answers 404 when no active Carbon owns the submitted identity.
That is a documented, deliberate disclosure: an identity provider that pretends to send a code
to a non-existent account teaches people to ignore a missing message.
Silicon login
POST /api/v1/silicon-auth/token takes the global Silicon ID as the username and the
stk-… token as the password, and returns the same credential pair a Carbon gets.
Silicon tokens are 128 bits, formatted as stk- plus 32 lowercase hexadecimal
characters, and are stored only as a keyed digest — a lost token can be replaced, never
recovered.
Step-up
Some actions need proof that the person at the keyboard is still the account holder. Step-up is
a second factor over an already-authenticated session, and the resulting token is bound to
one action on one resource: a token minted for
application.client_secret.rotate or
application.webhook_secret.rotate or application.webhook.approve
on application A is rejected for application B.
POST /api/v1/step-up/challengeswith the action, the resource ID and a channel.POST /api/v1/step-up/challenges/{session_id}/verifywith the code.- Send the resulting
sup_…value asX-Step-Up-Tokenalongside your bearer, within five minutes.
Actions that require it:
account.session_revoke,account.sessions_revoke_allorganization.transfer_ownership,organization.authorization_changeorganization.sso_change,organization.silicon_webhook.redirectapplication.client_secret.rotate,application.webhook_secret.rotate,application.webhook.approve,silicon.rotate_tokenplatform_admin.application_review,platform_admin.sso_entitlement
Do not cache a step-up token. It buys at most one prompt and leaves a live credential in memory between unrelated operations.
Verification-code protection
Every IAM-managed OTP is six digits and lives ten minutes. Signup, Carbon login, invitation-join and verified-channel step-up challenges each allow ten failed verifications; the tenth failure starts a sixty-second cooldown, after which the still-unexpired code gets a fresh ten-attempt window.
The partial failure count and any active cooldown carry into a replacement code. Resending cannot reset either, and a cooldown never extends the original ten-minute expiry. Interfaces should say so, or users will resend repeatedly in the belief that it helps.
The browser session
A successful Carbon login also sets iam_session: host-only, Secure,
HttpOnly, SameSite=Lax, and signed. It exists for the two flows that
are navigations rather than API calls — application login and SSO — and it is bound to the
same refresh family, so revoking the session revokes the cookie.
IAM recognizes an existing session and shows Application validation followed by
the current permission list with descriptions and critical labels, then its organization picker. Explicit permission consent precedes organization selection when consent_required is true. The user must choose at least one organization. Apps
cannot supply org_id. GET navigation never mints a token; the trusted
IAM interface submits explicit org_ids, the complete approved_scopes, and the exact scope_version returned by the choices API, using a direct IAM bearer. A scope change requires fresh consent.
Application scopes and credentials
A registration's app_scope declares permitted IAM data and external endpoints. Identity and profile are defaults; critical permissions require reviewer approval. The application receives only the scope intersection currently approved and consented by the user for selected active memberships. webhook_scope controls event subscriptions independently. A direct IAM bearer and an application OAuth bearer are distinct credentials with different authority.
IAM Carbon and Silicon sessions can read login choices and submit explicit consent. Application secrets and app-issued bearers cannot create or expand that consent. The only login handoff to an app is an app-bound, two-minute single-use SLT, exchanged on that app's backend with its own secret. Read Applications for the complete request shapes.
Batch and bundle login
/login?app_ids=tos%3Ebriefcase,tos%3Edm authenticates once for 1–100 distinct applications. IAM collects each app's permissions and organization choices, then creates all SLTs atomically. Direct IAM clients use GET /api/v1/app-auth/batch/organizations?app_ids=... followed by POST /api/v1/app-auth/batch/short-lived-tokens. Each element of applications contains app_id, org_ids, approved_scopes, and scope_version.
/login?bundle_id=tos%3Esuite displays one bundle identity with combined member permissions and a shared organization picker. The bundle routes validate the current exact member set. A bundle has no client secret, and every SLT remains bound to an individual application.
Both callbacks receive #slts= with a URL-encoded JSON array containing app_id, slt, expires_in, expires_at, and request_id. Read the fragment in browser JavaScript, verify login state and expected app IDs, clear it, and send each token to its own application server. See Batch login and Bundles.