Delegated access with OAuth 2.0
Open in the interactive app: practice, quiz, mock interview →In one minute
- OAuth 2.0 is a delegated authorization framework: a user lets an application act on their behalf at an API, without giving it their password.
- The authorization server issues an access token (short-lived) and often a refresh token (long-lived) to the client; the resource server checks the token.
- The authorization code flow with PKCE is the default for web, mobile and single-page apps; client credentials covers service-to-service.
- OAuth says nothing about who the user is; OpenID Connect adds an ID token for authentication.
- Most real breaches come from sloppy details: redirect URI matching, token storage, missing PKCE, over-broad scopes.
How it works
Roles
The resource owner is the user. The client is the app requesting access. The authorization server (AS) authenticates the user, asks for consent and issues tokens. The resource server (RS) is the API that accepts tokens. Scopes name the permissions requested, such as calendar.read.
Authorization code flow with PKCE
- The client generates a random
code_verifierand sends the user's browser to the AS withclient_id,redirect_uri,scope,stateandcode_challenge = BASE64URL(SHA256(code_verifier))withcode_challenge_method=S256(RFC 7636 §4.2). Theplainmethod, where the challenge equals the verifier, exists only for clients that cannot hash; GitHub, for one, refuses it (GitHub docs). - The user logs in and consents. The AS redirects back to
redirect_uriwith a one-timecodeand the samestate. - The client checks
state(CSRF protection), then calls the AS token endpoint directly with thecodeand the originalcode_verifier. - The AS checks the code is unused, unexpired (typically 60 seconds), bound to this client and redirect URI, and that the verifier hashes to the stored challenge. It returns an access token and maybe a refresh token.
The code travels through the browser, where it can leak via logs or malicious apps. PKCE makes a stolen code useless without the verifier, which never left the client. The OAuth Security BCP, RFC 9700 (January 2025), says public clients MUST use PKCE and confidential clients SHOULD; clients SHOULD NOT use the implicit flow; and the resource owner password grant, where the app collects the user's password itself, MUST NOT be used (RFC 9700 §2). OAuth 2.1, which folds these rules into one document and requires PKCE for all clients using the code flow, was still an Internet-Draft (revision 16, September 2026) at the time of writing (draft-ietf-oauth-v2-1).
Other grants
Client credentials: a backend authenticates as itself (secret, mTLS or signed JWT) and gets a token, no user involved. Device authorization (RFC 8628): a TV shows a short code, the user approves on a phone, and the TV polls, every 5 seconds unless told otherwise, backing off when the server answers slow_down. Refresh token: exchange for a new access token; with rotation, each use returns a new refresh token and reuse of an old one signals theft and revokes the family. RFC 9700 requires refresh tokens issued to public clients to be either rotated or sender-constrained.
Token formats and validation
Access tokens are either opaque (the RS calls the AS introspection endpoint, RFC 7662) or self-contained JWTs (the RS checks the signature with the AS's published keys, plus iss, aud, exp and scopes). JWTs avoid a network hop per request but cannot be revoked before expiry, so keep them short-lived (5 to 15 minutes). Opaque tokens can be revoked instantly but put the AS on the hot path.
Down the stack
Layer 1The contract
The authorization server promises to issue tokens only after authenticating the user and recording consent for the requested scopes. An authorization code is single-use, short-lived (RFC 6749 recommends at most 10 minutes; 30 to 60 seconds is common), and bound to the client and redirect URI that requested it (RFC 6749 §4.1.2). If a code is redeemed twice, RFC 9700 says the server should revoke every token already issued from it, because one of the two callers is an attacker. A resource server promises to honour a token only if its signature, issuer, audience, expiry and scopes all check out. Plain access tokens are bearer tokens: whoever holds one can use it, which is the property every other defence works around.
Layer 2The mechanism
The token request is a form-encoded POST: grant_type=authorization_code, code, redirect_uri, code_verifier, plus client authentication for confidential clients. The JSON response holds access_token, token_type: Bearer, expires_in, an optional refresh_token and the granted scope. A JWT access token is three base64url segments, header, claims and signature; the header names the algorithm (ES256, RS256) and a key id kid, which the resource server looks up in the JSON Web Key Set published at the server's jwks_uri. Refresh token rotation stores tokens in families: each refresh issues a child and invalidates the parent, and presenting an already-used token revokes the whole family. Sender-constrained tokens bind a token to a key the client holds, so a stolen token alone is useless. With DPoP the client signs a small JWT per request, covering the HTTP method, URL and a hash of the token, and the token carries a cnf claim with the key's thumbprint (RFC 9449); with mutual TLS the binding is to the client certificate (RFC 8705). A client that talks to several authorization servers also needs mix-up defence: the AS returns an iss parameter on the redirect and the client compares it with the server it started with (RFC 9207).
Layer 3The machine
Verifying an ES256 signature costs roughly 50 to 100 microseconds of CPU and RS256 verification about 20 to 50; RSA signing is the expensive side, around a millisecond for a 2048-bit key, which only the authorization server pays. A JWT with a few claims is 800 to 1,500 bytes and rides in every request's Authorization header; HTTP/2 header compression can index it, but proxies with 8 KB header limits reject tokens bloated with group lists. The PKCE verifier is 32 random bytes from the kernel CSPRNG (getrandom), 43 characters once base64url-encoded, giving 256 bits an attacker cannot guess. Expiry checks compare against the local clock, so servers allow a minute or two of skew and depend on NTP.
Why it's built this way
- Users should not give passwords to every app, so a trusted server issues limited tokens instead.
- The only channel between an arbitrary app and the authorization server is a browser redirect, which leaks easily, so the redirect carries a short-lived code and a back-channel exchange plus PKCE turns it into tokens.
- Many resource servers at high request rates cannot all call the authorization server, so tokens are signed and self-contained.
- Self-contained tokens cannot be recalled, so they are kept short-lived and renewed with refresh tokens.
- Bearer tokens work like cash, so high-value APIs bind tokens to client keys.
Diagram
sequenceDiagram
participant U as "User browser"
participant C as "Client app"
participant AS as "Authorization server"
participant RS as "Resource API"
C->>U: redirect with code_challenge, state
U->>AS: /authorize
AS->>U: login + consent
U->>C: redirect_uri?code=X&state=s
C->>AS: POST /token code=X, code_verifier
AS-->>C: access_token (JWT, 10 min), refresh_token
C->>RS: GET /events, Bearer token
RS->>RS: verify signature, aud, exp, scope
RS-->>C: 200 eventsWorked example
A SaaS product has 2 million users, 600,000 daily active, and an API serving 25,000 requests per second.
Opaque tokens with introspection would add 25,000 introspection calls per second to the AS, each 2 to 5 ms, and make the AS a single point of failure for every API call. A cache of introspection results for 60 seconds cuts this to roughly one call per active token per minute, but delays revocation by up to a minute.
Instead the team uses signed JWT access tokens with a 10-minute lifetime. API gateways verify ES256 signatures locally (around 50 microseconds each) using keys fetched from the AS's JWKS endpoint and cached. Each active session refreshes about every 10 minutes: 600,000 users active for an average 2 hours is 7.2 million refreshes per day, about 83 per second on average and maybe 400 at peak. That is the AS's real load.
Revocation: when an admin disables a user, their refresh token is revoked immediately, and the access token lives at most 10 more minutes. High-risk endpoints also check a small deny list of revoked sessions.
In the wild
- GitHub, 2022: OAuth tokens stolen from two integrators. An attacker called GitHub's API with OAuth user tokens issued to the Heroku Dashboard and Travis CI apps, listed victims' organizations and cloned private repositories, including npm's, where they found an AWS key. GitHub's own systems were not breached; the tokens came from the integrators. GitHub revoked tokens and asked both integrators to revoke theirs (GitHub). Heroku later said the attacker got in through a compromised machine-account token, and it revoked every token of its Dashboard GitHub integration (Heroku).
- Salesloft Drift, 2025: one integration's tokens opened many Salesforce orgs. From 8 to 18 August, an actor used stolen OAuth tokens for the Drift chat app to run bulk queries against many companies' Salesforce instances, then searched the exported records for AWS keys, passwords and Snowflake tokens. All Drift tokens were revoked on 20 August and the app was pulled from the AppExchange (Google Threat Intelligence).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| JWT access tokens | Local validation, no AS hop | Revocation waits for expiry | High-QPS APIs, many resource servers |
| Opaque tokens + introspection | Instant revocation, no data in token | AS on hot path, extra latency | Lower QPS, high-risk actions |
| Refresh token rotation | Detects stolen refresh tokens | Race conditions on parallel refresh | Public clients (SPAs, mobile) |
| Client credentials | Simple service auth | No user context | Machine-to-machine calls |
Pitfalls in production
- Loose redirect URI matching. Prefix or wildcard matching lets attackers redirect codes to their own pages. Require exact string matching of registered URIs; RFC 9700 allows only one exception, a varying port on
localhostfor native apps. - Missing
stateor PKCE. Enables CSRF login and code injection. Enforce both on every flow. - Tokens in localStorage. Any XSS can read them. Keep tokens on a backend (backend-for-frontend pattern) and give the browser an HttpOnly cookie.
- Not checking
aud. A token for one API is accepted by another. Validate audience and issuer, not just the signature. - Using access tokens as identity. OAuth access tokens are not meant for the client to read. Use OIDC ID tokens to learn who logged in.
- Long-lived third-party grants nobody reviews. An integration's refresh token is a standing key to your users' data. Let users and admins list and revoke grants, support token revocation (RFC 7009), and alert on unusual bulk reads by one client.
Leadership lens
- Buy or adopt a mature authorization server; writing one is a security product, not a feature.
- Define scope naming and granularity early, since scopes become part of every client integration.
- Ask in review: what is the maximum time a stolen token stays useful, and is that acceptable for this API?
Check yourself
What attack does PKCE stop that
statedoes not?Show answer
Authorization code interception: an attacker who steals the code cannot redeem it without the code verifier.
stateonly stops cross-site request forgery of the callback.Why are JWT access tokens kept short-lived?
Show answer
Resource servers validate them locally and cannot see revocation, so expiry is the main bound on how long a leaked or revoked token works.
Why is OAuth alone not a login protocol?
Show answer
An access token says what a client may do at an API, not who the user is or that they just authenticated; OIDC's ID token carries that.
Sources and further study
Primary
- The OAuth 2.1 Authorization Framework (Internet-Draft): RFC 6749 plus a decade of security fixes in one document.
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP): sender-constrained tokens without mutual TLS.
- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification: the
issparameter that stops mix-up attacks. - RFC 8628: OAuth 2.0 Device Authorization Grant: the TV and CLI flow, with polling and
slow_down.
Systems docs
- GitHub Docs: Authorizing OAuth apps: a real provider's web flow with S256-only PKCE and the device flow.
- Auth0: Refresh Token Rotation: token families and automatic reuse detection in a production authorization server.
Books
- Building Microservices (2nd ed.), Sam Newman, Ch 11 "Security": human and service-to-service authentication, and the confused deputy problem that delegated tokens address.
Other curricula
- OWASP OAuth 2.0 Cheat Sheet: the Security BCP turned into a checklist for client and server builders.
- Azure Architecture Center: Federated Identity pattern: frames OAuth and OIDC as delegating authentication to an IdP, with authorization code plus PKCE as the worked example.
Watch
- OAuth 2.0 and OpenID Connect in plain English (OktaDev): a well-known walkthrough of flows and terminology.
- An Illustrated Guide to OAuth and OpenID Connect (OktaDev): short visual explanation of the code flow.
Free resources
- RFC 6749: The OAuth 2.0 Authorization Framework: the base specification.
- RFC 7636: PKCE: the code challenge mechanism.
- RFC 9700: OAuth 2.0 Security Best Current Practice: current guidance, including dropping implicit flow.
- RFC 9068: JWT Profile for Access Tokens: standard claims and validation rules.