Skip to content

Authentication

The platform has four separate credential types, and mixing them up is the most common first-day mistake. Each surface accepts exactly one.

SurfaceCredentialLooks like
Management API (/v1/*)Org API keysk_live_…
Protocol + credential endpointsOAuth access tokena JWT, Bearer or DPoP
Directory sync (/scim/v2/*)SCIM bearer tokenscim_…
Hosted screens (/login/*, /sessions, /mfa/*, /connect/*)Session cookieset by the platform

Every /v1 call authenticates with an org API key as a bearer token:

GET /v1/organizations/org_01J…/members HTTP/1.1
Host: acme.oauth.work
Authorization: Bearer sk_live_…

Keys are stored only as a SHA-256 hash. The full value is returned once, at creation, and cannot be read back afterwards — not through the API, not through the console. Losing one means minting a replacement and revoking the old.

Each key carries a scope list drawn from the permission set, and is bound either to one org or to the platform. A key scoped to an org may only act on that org: a request for another org’s resources fails with 403 forbidden, even when the key holds the right permission.

// POST /v1/organizations/org_01J…/api_keys
{ "name": "ci-deploy", "scopes": ["credentials:issue", "credentials:read"] }

A key can only mint keys at or below its own privilege. Requesting a scope the calling key does not hold returns 403 insufficient_scope, and requesting a scope that is not a known permission returns 400 invalid_scope — so a typo fails loudly instead of creating a key that silently grants nothing.

Keys record a last_used_at, written at roughly five-minute resolution to keep the read path cheap. Use it to find keys nobody is calling any more, not to audit individual requests — the audit log is the record of what a key actually did.

The protocol surface — /userinfo, /credentials/*, /mcp, /introspect — takes an OAuth access token issued by /token. Tokens are EdDSA-signed JWTs, and a resource server should verify them against the JWKS resolved from the token’s own iss:

GET /userinfo HTTP/1.1
Host: acme.oauth.work
Authorization: Bearer eyJhbGciOiJFZERTQSIs…

If the token was issued under a DPoP proof, the scheme changes to DPoP and every request must carry a fresh proof bound to the same key:

GET /userinfo HTTP/1.1
Authorization: DPoP eyJhbGciOiJFZERTQSIs…
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…

Presenting a DPoP-bound token as Bearer, or with a proof signed by a different key, is rejected. That is the point of the binding: possession of the token alone is not enough.

Tokens issued with a resource indicator carry that value as aud, and a resource server must reject a token whose audience is not its own — otherwise a token minted for one service is replayable at another. See scopes, claims, and discovery.

Directory sync uses its own bearer token, minted per connection so that revoking a customer’s SCIM integration does not disturb anything else they have:

POST /v1/organizations/org_01J…/directory/scim_token
Authorization: Bearer sk_live_…
{ "connection_id": "conn_…" }
{
"token": "scim_…",
"scim_base_url": "https://acme.oauth.work/scim/v2"
}

Hand both values to the customer’s identity provider. Like API keys, the token is shown once. It authorizes the SCIM endpoints only — it cannot call /v1. See SCIM 2.0.

The hosted sign-in screens, the end-user account screens, and the connected-accounts flow run on a session cookie the platform sets after a successful login. The cookie is host-scoped to the tenant, so a flow that starts on acme.oauth.work finishes there.

These endpoints are same-origin by design and send no CORS headers, so a separate frontend cannot call them directly. To sign a user in from your own application, send them to /authorize — the hosted screens run the login, and you receive a code. The endpoints are documented in login methods so you can reason about what the hosted flow does and what it records, not as an API to call cross-origin.

The public discovery documents (/.well-known/openid-configuration, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, and /.well-known/jwks.json) do send CORS headers, because browser-based relying parties and MCP inspectors have to fetch them cross-origin.

A confidential OAuth client authenticating at the token endpoint is a different thing again from any of the above — it proves the client’s identity, not a user’s or an operator’s. The platform accepts client_secret_basic, client_secret_post, private_key_jwt, and none (public clients, PKCE enforced). See client authentication.