Skip to content

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.

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.

Terminal window
npx @oauth-work/cli init \
--tenant acme \
--name "Acme Corp" \
--email you@acme.com

The 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.

Discovery is the fastest check that everything is wired up. It is public — no credential needed.

Terminal window
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.

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:

Terminal window
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.

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=S256

The 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.work

Check 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.

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.

Terminal window
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.

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.

Whichever of these your product needs first: