Quick start
Run "redirect → consent → callback → token exchange → userinfo" in fifteen minutes.
This page walks a minimal confidential client (web_server) through the whole flow. Everything here is standard OAuth 2.0 — swap in your preferred client library and the parameter meanings stay the same.
0. Prerequisites
| Need | Where it comes from |
|---|---|
client_id / client_secret | See Registering a client |
redirect_uri | A registered callback, e.g. https://your-app.example.com/callback |
issuer | e.g. https://id.example.com/v1/oauth |
authorization_endpoint | Read from metadata, e.g. https://id.example.com/authorize |
Minimal permission set for this walkthrough: openid profile email. Add offline_access only if you need long-term access.
1. Generate PKCE and state
import { randomBytes, createHash } from 'node:crypto'
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url')
const state = randomBytes(16).toString('base64url')The verifier and state must be bound to the current browser session (an HttpOnly cookie or a server-side session), so the callback can retrieve them for comparison.
2. Redirect the browser to the consent UI
const url = new URL('https://id.example.com/authorize')
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('redirect_uri', REDIRECT_URI)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')
return Response.redirect(url, 302)Do not hand-build /v1/oauth/authorize
The server's /v1/oauth/authorize is an internal JSON endpoint used by the consent UI to submit its
decision, and accepts POST only. Browser redirects must target the metadata's authorization_endpoint
(the consent UI address).
3. The user confirms on the consent page
The consent page shows the application name and each permission. When the user clicks "Allow", the browser is redirected (302) back to your redirect_uri:
https://your-app.example.com/callback?code=...&state=...&iss=https%3A%2F%2Fid.example.com%2Fv1%2FoauthThree things in the callback handler, in this order:
- Compare
state; mismatch means abort immediately (anti-CSRF). - Compare
issagainst the issuer you trust; mismatch means abort immediately (RFC 9207, anti-mix-up). - Take the
codeand exchange it immediately (authorization codes are single-use with a minute-scale lifetime).
If the user denies, the callback looks like:
https://your-app.example.com/callback?error=access_denied&state=...&iss=...4. Exchange the code
curl -X POST 'https://id.example.com/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d 'grant_type=authorization_code' \
-d "code=$CODE" \
-d "redirect_uri=$REDIRECT_URI" \
-d "code_verifier=$VERIFIER"Successful response:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email offline_access",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}Notes:
redirect_urimust be exactly the value used in the authorization request, or the exchange fails.code_verifiercorresponds to thechallengefrom step 1; theplainmethod is not accepted.- The response carries
Cache-Control: no-store; nothing along the path may cache it.
5. Read the user's identity
curl 'https://id.example.com/v1/auth/userinfo' \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"sub": "0192f3a4-...",
"preferred_username": "muoceon_user",
"name": "Display Name",
"email": "user@example.com",
"email_verified": true
}sub is the stable user identifier — use it as the account key. Do not use email: email can change, sub does not.
Checkpoints
| Symptom | Usual cause |
|---|---|
| Consent page says the client does not exist | client_id unregistered or mistyped |
| Consent page rejects the redirect URI | Address not registered, or redirect_uri differs by a trailing slash |
invalid_grant on exchange | Code already used or expired, or code_verifier does not match the challenge |
invalid_client on exchange | Confidential client without its secret, or the wrong auth method |
| UserInfo audience mismatch | An oauth audience token was used, or the scope lacks openid/profile/email |
| Received fewer scopes than requested | The server filters by the registered allowlist; filtered scopes do not raise errors — see Scopes and audiences |
Once this works, read Authorization code flow to cover edge cases, then Tokens to make validation solid.