Quickstart
This guide takes you from nothing to a verified ID token. It uses one tenant (acme), one client,
and one user, and every step is a real request you can run.
You will need a terminal with Node 18+ for step 1. Everything after that is plain HTTP.
1. Create a tenant
Section titled “1. Create a tenant”A tenant is host-addressed: acme.oauth.work gets its own OIDC discovery document, its own JWKS,
its own Ed25519 signing key, and its own did:web issuer. Signup is an email one-time code, so use
an address you can read.
npx @oauth-work/cli init \ --tenant acme \ --name "Acme Corp" \ --email you@acme.comThe CLI prompts for the code it just emailed you (pass --code 123456 in CI, where there is no
TTY), then prints the tenant:
Organization created. issuer: https://acme.oauth.work org id: org_01J... slug: acme api_key: sk_live_...
Save the api_key now — it is shown only once.Keep the api_key. It is the credential for the management API, it is stored
only as a hash, and there is no way to read it back — if you lose it, mint a new one and revoke the
old.
2. Confirm the tenant is live
Section titled “2. Confirm the tenant is live”Discovery is the fastest check that everything is wired up. It is public — no credential needed.
curl -s https://acme.oauth.work/.well-known/openid-configuration{ "issuer": "https://acme.oauth.work", "authorization_endpoint": "https://acme.oauth.work/authorize", "token_endpoint": "https://acme.oauth.work/token", "jwks_uri": "https://acme.oauth.work/.well-known/jwks.json", "registration_endpoint": "https://acme.oauth.work/register", "id_token_signing_alg_values_supported": ["EdDSA"], "code_challenge_methods_supported": ["S256"]}Every URL in that document is on the tenant host. That is the whole of multi-tenancy here: your
customer’s issuer is not a path or a query parameter, it is their own origin, and the key behind
jwks_uri is theirs alone. See multi-tenancy.
3. Register a client
Section titled “3. Register a client”Your app needs a client_id. Register one with dynamic client registration
(RFC 7591) — no credential required, which is what lets MCP clients self-register:
curl -s https://acme.oauth.work/register \ -H 'content-type: application/json' \ -d '{ "client_name": "Acme Web", "redirect_uris": ["https://app.acme.com/callback"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none", "scope": "openid profile offline_access" }'{ "client_id": "client_7f3a…", "client_id_issued_at": 1750000000, "redirect_uris": ["https://app.acme.com/callback"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none", "scope": "openid profile offline_access"}token_endpoint_auth_method: "none" makes this a public client, which is right for an SPA or a
native app: PKCE is what protects it, and there is no secret to leak. A server-side app should ask
for client_secret_basic instead and will receive a client_secret in the response — shown once.
4. Send the user to authorize
Section titled “4. Send the user to authorize”OAuth 2.1 makes PKCE mandatory, so generate a verifier and its S256 challenge, then redirect the browser:
GET https://acme.oauth.work/authorize ?response_type=code &client_id=client_7f3a… &redirect_uri=https://app.acme.com/callback &scope=openid%20profile%20offline_access &state=<opaque, checked on return> &code_challenge=<S256(verifier)> &code_challenge_method=S256The user signs in on the tenant’s hosted screens — email code, magic link, password, passkey, or social, whichever you have enabled — consents, and comes back:
302 https://app.acme.com/callback?code=<code>&state=…&iss=https://acme.oauth.workCheck state against what you sent, and check iss matches the issuer you started at (RFC 9207);
both are there to stop a response from one authorization server being replayed at another.
5. Exchange the code
Section titled “5. Exchange the code”The code is single-use. It lives in a Durable Object and is deleted atomically on redemption, so a
replay always fails closed with invalid_grant.
curl -s https://acme.oauth.work/token \ -d grant_type=authorization_code \ -d code=<code> \ -d client_id=client_7f3a… \ -d redirect_uri=https://app.acme.com/callback \ -d code_verifier=<verifier>{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile offline_access", "id_token": "eyJ…", "refresh_token": "rt_…"}The refresh token appears because offline_access was granted. It rotates on every use and is
covered by reuse detection — see token lifecycle.
6. Verify the ID token
Section titled “6. Verify the ID token”ID tokens are EdDSA-signed with the tenant’s key. Verify against the tenant’s JWKS, and check the
issuer and audience:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const jwks = createRemoteJWKSet(new URL('https://acme.oauth.work/.well-known/jwks.json'))
const { payload } = await jwtVerify(idToken, jwks, { issuer: 'https://acme.oauth.work', audience: 'client_7f3a…',})// payload.sub — stable user id// payload.org_id — the tenant the user belongs to// payload.roles — their role in that org// payload.amr — how they authenticated, e.g. ["otp"] or ["pwd","totp"]Resolve the key set from the token’s own iss, not from a hardcoded URL. Tenant-issued and
platform-issued tokens are signed by different keys, and rotation publishes a new kid alongside
the old one — following iss keeps verification correct through both.
What to do next
Section titled “What to do next”Whichever of these your product needs first:
- Selling to enterprises — OIDC federation and SAML let a customer sign in with their own identity provider; SCIM keeps the roster in sync; the Admin Portal lets their IT admin configure it without going through you.
- Building with agents — MCP authorization for scoped tool access, DPoP so the tokens cannot be replayed elsewhere, A2A delegation for agent-to-agent chains.
- Operating it — webhooks and audit logs for what happened, roles and permissions for who may do what.