OAuth 2.x the current way
Generated by Claude (Fable 5.1) by hand from the identity and integration ops spike, Scout learning center on 2026-10-14, reviewed by a second reader (Claude Fable 5.1), Scout learning center on 2026-10-14.
Concept: OAuth 2.x · also covers API design, Identity and access · Lesson 3 of 10 in Identity and integration ops · version 1
Practice thisOn this page
OAuth 2.0 is how a client gets a token to call an API on someone's behalf, and most of what was written about it in its first decade is now wrong. This lesson teaches the version that remains under OAuth 2.1 and the 2025 security best current practice (RFC 9700): the four roles, authorization code with PKCE for anything that has a user, client credentials for machines, the device grant for things without a browser, refresh tokens with rotation, token exchange for delegation chains, and introspection and revocation for the resource-server side. The implicit and password grants are taught as history so you recognise them in a customer's old integration and know what to replace them with. Every grant except the device grant and token exchange was run against a local Keycloak and a local node-oidc-provider in the practice.
Read more
- guideOAuth 2.0 Simplified: Protecting Apps with PKCE (www.oauth.com) checked 2026-10-14
- guideOkta: An Illustrated Guide to OAuth and OpenID Connect (developer.okta.com) checked 2026-10-14
- guideAuth0: Which OAuth 2.0 Flow Should I Use? (auth0.com) checked 2026-10-14
How it is done in practice
On an integration call the solutions engineer's first OAuth question is 'what kind of client is this', because the answer picks the grant: a web app or mobile app with a user gets authorization code with PKCE; a nightly job or a service gets client credentials; a CLI on a developer's laptop gets the device grant or a loopback redirect. The second question is 'who runs the authorization server', because the customer's provider (Okta, Entra, Keycloak, Auth0) decides the exact endpoints, which client authentication it supports and how long tokens live. Then the engineer reads the discovery document, registers the client with an exact redirect URI, runs the flow once with curl or a script to see every response, and writes the resource server's validation with introspection for opaque tokens or local JWT validation for self-contained ones. When a customer's existing integration uses the implicit grant or sends a username and password to the token endpoint, the engineer names the RFC that retired it and proposes the replacement in the same meeting.
Technologies
Diagram
sequenceDiagram participant U as User's browser participant C as Client (public, PKCE) participant A as Authorization server participant R as Resource server C->>C: code_verifier, code_challenge = S256(verifier), state C->>U: redirect to /authorize?response_type=code&client_id&redirect_uri&scope&state&code_challenge U->>A: login and consent A->>U: 302 redirect_uri?code=...&state=...&iss=... U->>C: callback: check state and iss C->>A: POST /token grant_type=authorization_code&code&code_verifier A-->>C: access_token, id_token, refresh_token C->>R: Authorization: Bearer access_token R->>A: POST /introspect (opaque token) or validate the JWT locally
sequenceDiagram participant U as User's browser participant C as Client (public, PKCE) participant A as Authorization server participant R as Resource server C->>C: code_verifier, code_challenge = S256(verifier), state C->>U: redirect to /authorize?response_type=code&client_id&redirect_uri&scope&state&code_challenge U->>A: login and consent A->>U: 302 redirect_uri?code=...&state=...&iss=... U->>C: callback: check state and iss C->>A: POST /token grant_type=authorization_code&code&code_verifier A-->>C: access_token, id_token, refresh_token C->>R: Authorization: Bearer access_token R->>A: POST /introspect (opaque token) or validate the JWT locally
Code
python# PKCE: the verifier stays with the client, the S256 challenge goes to the authorization endpoint
import base64, hashlib, secrets, requests
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
disc = requests.get(ISSUER + "/.well-known/openid-configuration").json()
authorize_url = requests.Request("GET", disc["authorization_endpoint"], params={
"response_type": "code", "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
"scope": "openid profile email", "state": state,
"code_challenge": challenge, "code_challenge_method": "S256"}).prepare().url
# ... the browser logs in and comes back to REDIRECT_URI?code=...&state=... ; check state first ...
tokens = requests.post(disc["token_endpoint"], data={
"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID, "code_verifier": verifier}).json() # no secret: a public client
python# Client credentials, introspection and revocation (the learning center's run, trimmed; the secret comes from the environment)
import os, requests
cid, secret = os.environ["CLIENT_ID"], os.environ["CLIENT_SECRET"]
t = requests.post(disc["token_endpoint"], auth=(cid, secret), data={"grant_type": "client_credentials"}).json()
# {'token_type': 'Bearer', 'expires_in': 300, 'scope': 'profile email', 'access_token': '...'} no refresh_token
i = requests.post(disc["introspection_endpoint"], auth=(cid, secret), data={"token": t["access_token"]}).json()
# {'active': True, 'client_id': 'm2m-batch', 'username': 'service-account-m2m-batch', ...}
requests.post(disc["revocation_endpoint"], auth=(cid, secret), data={"token": t["access_token"]})
# introspect again -> {'active': False}
Subtopics
The four roles and the words on the call
Resource owner, client, authorization server, resource server
The resource owner is the user (or, for machine access, the organisation) whose data is at stake. The client is the application asking for access: a web app, a mobile app, a CLI, a batch job. The authorization server issues tokens after authenticating the resource owner and recording consent; at a customer this is their identity provider or a dedicated authorization server in front of their APIs. The resource server is the API that accepts tokens. Okta's illustrated guide draws these four as a story and is the fastest way to get a non-specialist to the same vocabulary; the RFC 6749 definitions are the reference when a vendor uses the words loosely.
Public and confidential clients, and the words that follow
A confidential client can keep a secret (a server-side app, a daemon); a public client cannot (a single-page app, a mobile app, a CLI on a laptop), and that distinction decides client authentication and why PKCE exists. A grant is the way a client obtains a token; a scope is a string naming what the token may do; a redirect URI is where the authorization server sends the browser back, and the current rule is exact string matching, no wildcards and no path prefixes. An access token is what the client shows to the resource server; a refresh token is what the client shows the authorization server to get a new access token without the user. Bearer means whoever holds the token can use it, which is why every token travels over TLS and never in a URL.
Authorization code with PKCE
The flow
The client generates a random code_verifier (43 to 128 characters), hashes it with SHA-256 and base64url-encodes the hash as the code_challenge, and sends the browser to the authorization endpoint with response_type=code, its client_id, the exact redirect_uri, the scopes, a random state, and code_challenge with code_challenge_method=S256. The user authenticates and consents; the authorization server redirects the browser back to the redirect_uri with a short-lived code and the same state. The client checks state, then posts the code to the token endpoint together with the original code_verifier (and its client secret if it is confidential); the server hashes the verifier, compares it with the challenge it stored with the code, and returns the tokens. An attacker who intercepts the code cannot exchange it, because they do not hold the verifier. OAuth 2.1 requires PKCE for every client; RFC 9700 requires it for public clients and recommends it for confidential ones, which may instead rely on the OpenID Connect nonce with extra care. A code may be used once: RFC 6749 says a server that sees a code twice must refuse it and should revoke what it already issued for it, and Keycloak also detaches that client's session from the user's session when a code is reused.
What the proof showed
The learning center's scripted client ran this against Keycloak 26.4 with a public client that requires S256: the discovery document listed S256 among code_challenge_methods_supported, the login POST redirected to the callback with a code of 110 characters and the matching state, the token endpoint returned access, ID and refresh tokens for the right verifier and answered 400 invalid_grant to the same code with a wrong verifier. node-oidc-provider refused an authorization request with no PKCE outright ('Authorization Server policy requires PKCE') and added an iss parameter to the redirect back (RFC 9207), which a client should compare with the issuer it started with so a response from one provider cannot be mixed up with another.
Client credentials
Machines calling APIs
When there is no user, the client authenticates as itself and asks for a token: a POST to the token endpoint with grant_type=client_credentials, the client id and secret in an HTTP Basic Authorization header (the RFC's preferred form; a form body is also allowed), and optionally a scope. The token represents the client, not a person: in Keycloak the subject is a service account user created for the client, and the token from the test run carried azp m2m-batch and preferred_username service-account-m2m-batch. There is no refresh token, because the client can simply ask again; the Newman collection in the API testing lesson asserts exactly that.
At a customer
Okta's service app and Entra's app registration with application permissions are the two flavours you will configure; both require an administrator to grant the permissions (admin consent in Entra) before the token is useful, which is the step customers forget. The secret is a credential: rotate it, keep it out of repositories and collections, and consider private_key_jwt or mTLS instead of a shared secret where the provider supports it (the machine-to-machine lesson covers both). The token lifetime is the only revocation you get for a JWT access token, so keep it short; the practice realm uses five minutes.
The device authorization grant
For things without a browser
A CLI, a TV app or a device in a kiosk cannot host a redirect. Under RFC 8628 the client posts to the device authorization endpoint and receives a device_code, a short user_code, a verification_uri and a polling interval. It shows the user 'go to this URL and enter this code', and polls the token endpoint with grant_type urn:ietf:params:oauth:grant-type:device_code until the user has approved, respecting the interval (a slow_down error means back off). The user authenticates on any browser, on any machine, with the provider's full login including MFA, which is why this grant replaced the password grant for CLIs.
Where you will see it
Cloud CLIs, the GitHub CLI and most developer tools that log in from a terminal use it, and customers ask for it when an integration runs on a server with no browser but still needs a real user's delegated access. Keycloak, Okta and Entra all support it behind a per-client switch. It was read, not run, in the practice; the Keycloak step is there for readers who want to see it end to end.
Refresh tokens and rotation
Staying logged in without the user
An access token should be short-lived; a refresh token lets the client obtain a fresh access token by sending grant_type=refresh_token to the token endpoint, with client authentication for a confidential client. The learning center's run refreshed the user token against Keycloak and received a new access token that differed from the first. Refresh tokens are long-lived credentials, so OAuth 2.1 and RFC 9700 require that for public clients they are either sender-constrained or rotated: each refresh returns a new refresh token and invalidates the old one, and a reuse of an old one (two parties holding the same token) invalidates the whole family. Providers expose this as a switch (Keycloak: 'Revoke Refresh Token' in the realm's token settings; Okta: refresh token rotation on the app integration).
Lifetimes to write down
For every integration write down the access token lifetime, the refresh token lifetime (idle and absolute), whether rotation is on, and what a user sees when the refresh finally fails (a re-login, not an error page). The offline_access scope in OpenID Connect asks for a refresh token that survives the browser session, for jobs that act on a user's behalf while they are away, and customers' security teams will ask why it is requested.
Token exchange, introspection and revocation
Token exchange (RFC 8693)
In a chain of services, service A received a token for itself and needs to call service B on the same user's behalf. Forwarding A's token is wrong (B is not its audience) and minting a new one from nothing loses the user. Token exchange is a token endpoint grant (urn:ietf:params:oauth:grant-type:token-exchange) where A presents its subject_token and asks for a token with a different audience or scope, optionally with an actor_token to record that A acted for the user (delegation) rather than as the user (impersonation). Keycloak supports it; Entra's on-behalf-of flow solves the same problem with a different grant type (the jwt-bearer grant, not RFC 8693). It was read, not run.
Introspection (RFC 7662) and revocation (RFC 7009)
A resource server that holds an opaque token, or that wants to know whether a JWT has been revoked, posts the token to the introspection endpoint with its own client authentication and gets back a JSON object with active true or false and, when active, the claims (client_id, scope, exp, username). Revocation is the mirror image: the client posts a token to the revocation endpoint and the server stops honouring it. The learning center ran both against Keycloak and node-oidc-provider: a fresh client-credentials token introspected as active with the right client_id, a token with five characters changed introspected as active false, a wrong client secret at the token endpoint answered 401 unauthorized_client, and after revocation the same token introspected as active false. Those four results are the resource-server side of the lesson, and the Newman collection repeats two of them.
The retired grants, taught as history
Implicit
The implicit grant returned the access token in the URL fragment of the redirect, straight to the browser, with no code and no client authentication. It existed because browsers could not make cross-origin token requests before CORS; it leaked tokens through browser history, referrers and logs, and could not bind the token to the client. RFC 9700 says not to use it and OAuth 2.1 removes it; the replacement for a single-page app is authorization code with PKCE, which every provider supports. Okta's console still shows an 'Implicit (hybrid)' checkbox; leave it off.
Resource owner password credentials
The password grant let a client collect the user's username and password and post them to the token endpoint. It trained users to type their password into third-party apps, bypassed MFA and single sign-on, and gave the client the password itself. It is removed in OAuth 2.1 and the device grant or a loopback redirect replaces it for CLIs. You will meet it in customers' older integrations and in Keycloak's 'Direct Access Grants' switch (off on the practice clients); the conversation is 'this is retired by the standard your security team follows, here is the grant that replaces it'. Bearer tokens in query strings go the same way: OAuth 2.1 allows them only in the Authorization header or the body.
Scopes, consent and least privilege
What a scope is
A scope is a string the client asks for and the authorization server may grant, carried in the token's scope claim and checked by the resource server. OpenID Connect defines openid, profile, email, address, phone and offline_access; APIs define their own (read, write, a resource name). The server may grant fewer scopes than requested and reports what it granted in the token response; a client has to read that field rather than assume. In the test run Keycloak returned 'profile email' for a client-credentials request that asked for nothing, and Keycloak serves userinfo only to a token that carries openid, which is the kind of provider-specific rule a lesson has to say out loud.
Consent at a customer
In a workforce setting consent is usually granted once by an administrator for the whole tenant (Entra's admin consent, Okta's assignment of the app to users or groups), and a user who is not assigned gets an error instead of a login. Ask for the minimum scopes the integration needs, name them in the design document with what each is for, and expect the customer's security review to go through the list. Scopes authorize the client; they do not replace the resource server's own check of what this user may do with this record.
Sources
- guideOAuth 2.0 Simplified: Protecting Apps with PKCE (www.oauth.com) checked 2026-10-14
- guideOkta: An Illustrated Guide to OAuth and OpenID Connect (developer.okta.com) checked 2026-10-14
- guideAuth0: Which OAuth 2.0 Flow Should I Use? (auth0.com) checked 2026-10-14
- guideoauth.net: OAuth 2.1 (oauth.net) checked 2026-10-14
- guideOWASP OAuth 2.0 Protocol Cheat Sheet (cheatsheetseries.owasp.org) checked 2026-10-14
- specRFC 6749: The OAuth 2.0 Authorization Framework (datatracker.ietf.org) checked 2026-10-14
- specRFC 7636: Proof Key for Code Exchange by OAuth Public Clients (datatracker.ietf.org) checked 2026-10-14
- specRFC 9700: Best Current Practice for OAuth 2.0 Security (datatracker.ietf.org) checked 2026-10-14
- specThe OAuth 2.1 Authorization Framework (draft) (datatracker.ietf.org) checked 2026-10-14
- specRFC 8628: OAuth 2.0 Device Authorization Grant (datatracker.ietf.org) checked 2026-10-14
- specRFC 8693: OAuth 2.0 Token Exchange (datatracker.ietf.org) checked 2026-10-14
- specRFC 7662: OAuth 2.0 Token Introspection (datatracker.ietf.org) checked 2026-10-14
- bookOAuth 2 in Action (Manning, paid) (www.manning.com) checked 2026-10-14