OAuth 2.0 answers one question: how can an application get limited access to something on a user's behalf, or on its own behalf, without ever handling the user's password? The answer is a token issued by a server the resource owner trusts, scoped to specific permissions, with a short lifetime, and accepted by an API that never needs to see a password. It is a framework for delegated authorization. It is not, by itself, a login protocol, and most real security bugs in OAuth deployments come from forgetting that or from using one of its older, now discouraged flows.
This article explains the framework from first principles: the four roles and their endpoints, what goes over the wire in the authorization code grant, how to choose a grant, how access and refresh tokens work, how sender-constrained tokens stop stolen tokens from being replayed, where OpenID Connect fits, and what the current security best practice requires. PKCE has its own step-by-step article, so here it is covered only where it fits into the code exchange.
Four roles and two endpoints
RFC 6749 defines four roles. The resource owner is usually a person who can grant access. The client is the application that wants access; it can be confidential, meaning it can keep a secret because it runs on a server, or public, meaning it cannot, such as a single-page application or a mobile app. The authorization server authenticates the owner, asks for consent and issues tokens. The resource server is the API that accepts tokens.
The authorization server exposes two main endpoints. The authorization endpoint is visited by the user's browser and is where login and consent happen. The token endpoint is called directly by the client, server to server, to exchange a grant for tokens. Most servers also publish a metadata document at /.well-known/oauth-authorization-server (RFC 8414), or the OpenID Connect equivalent, listing these endpoints, the supported grants and the signing keys, so clients can be configured from one URL.
The authorization code grant on the wire
The authorization code grant is the main flow for anything involving a user. The client first redirects the browser to the authorization endpoint:
GET /authorize?response_type=code
&client_id=shop-web
&redirect_uri=https%3A%2F%2Fshop.example%2Fcallback
&scope=orders.read%20orders.write
&state=Zx81kq2... (random, bound to the user's session)
&code_challenge=E9Melhoa2Owv... (PKCE: SHA-256 of a secret verifier)
&code_challenge_method=S256
Host: auth.exampleAfter the user logs in and consents, the server redirects back to the registered redirect_uri with ?code=...&state=.... The client checks that state matches the value it stored, which blocks cross-site request forgery on the callback, and then redeems the code on a back channel:
POST /token
Host: auth.example
Authorization: Basic c2hvcC13ZWI6c2VjcmV0 (confidential client authentication)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fshop.example%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"access_token":"eyJhbGciOi...","token_type":"Bearer","expires_in":600,
"refresh_token":"8xLOxBtZp8...","scope":"orders.read orders.write"}The code is single use and short-lived; RFC 6749 recommends a maximum of ten minutes, and in practice it is often under one. It is useless without the client's credentials and the PKCE verifier, which never passed through the browser. That is the core security property of the flow: the browser, its history and any logs along the redirect path only ever see a short-lived code, not a token. A public client, such as a single-page or mobile app, has no credentials, so for it the PKCE verifier is the only proof that whoever redeems the code also started the flow; an intercepted code alone cannot be exchanged. For a confidential client, PKCE also blocks an attacker from injecting a stolen code into the client's own callback. The client then calls the API with Authorization: Bearer <token> as defined in RFC 6750.
Choosing a grant
| Situation | Grant | Notes |
|---|---|---|
| User signs in to a web, mobile or single-page app | Authorization code with PKCE | PKCE for every client type, public or confidential |
| Service calls another service as itself | Client credentials | No user; scopes describe what the service may do |
| TV, CLI or device with no browser or keyboard | Device authorization grant (RFC 8628) | User approves on a second device |
| Renewing access without the user | Refresh token | Rotate or sender-constrain for public clients |
| Legacy: token in the redirect URL | Implicit | RFC 9700 says it SHOULD NOT be used |
| Legacy: app collects the password | Resource owner password credentials | RFC 9700 says it MUST NOT be used |
The OAuth 2.0 Security Best Current Practice, published as RFC 9700 in January 2025, collects a decade of lessons: exact string matching of redirect URIs, PKCE for authorization code clients, no implicit or password grants, short-lived and audience-restricted access tokens, and protection for refresh tokens. OAuth 2.1 folds the same rules into a single document but, at the time of writing, is still an Internet-Draft; you can follow RFC 9700 today without waiting for it.
Client credentials and the device grant
Client credentials is the simplest grant: the client authenticates and asks for a token for itself.
import time, requests
class TokenSource:
def __init__(self, token_url, client_id, client_secret, scope):
self.url, self.auth, self.scope = token_url, (client_id, client_secret), scope
self.token, self.expires_at = None, 0
def get(self):
# refresh a little early so an in-flight request never carries an expired token
if self.token is None or time.time() > self.expires_at - 30:
r = requests.post(self.url, auth=self.auth, timeout=5,
data={"grant_type": "client_credentials", "scope": self.scope})
r.raise_for_status()
body = r.json()
self.token = body["access_token"]
self.expires_at = time.time() + body.get("expires_in", 300)
return self.token
tokens = TokenSource("https://auth.example/token", "billing-job", SECRET, "invoices.write")
requests.post(API, headers={"Authorization": "Bearer " + tokens.get()}, json=payload)Cache the token until shortly before expiry; fetching a new one per request overloads the authorization server and adds latency. Prefer private key JWT or mutual TLS client authentication over shared secrets where your server supports them, because a secret in a configuration file is easy to leak.
The device grant suits devices without a usable browser. The device 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 code to the user, who enters it on a phone or laptop, while the device polls the token endpoint with grant type urn:ietf:params:oauth:grant-type:device_code. Until approval the server answers authorization_pending; slow_down means increase the interval.
Access tokens: opaque or JWT
OAuth does not define what an access token looks like. There are two common designs. An opaque token is a random reference; the resource server asks the authorization server what it means through the introspection endpoint (RFC 7662), which returns active, the scopes, the subject, the client and the expiry. A structured token is usually a signed JWT, profiled for access tokens by RFC 9068, which the resource server validates locally with the issuer's public keys.
| Opaque plus introspection | JWT access token | |
|---|---|---|
| Validation | Network call per token, usually cached | Local signature and claim checks |
| Revocation | Immediate | Effective only at expiry, unless you add a deny list |
| Privacy | Contents hidden from the client | Claims readable by anyone holding the token |
| Availability | API depends on the introspection endpoint | API works if the server is briefly down |
Whichever you choose, the resource server must check more than the signature: the issuer, that the audience is this API, the expiry, and that the scopes cover the operation. A token issued for one API and accepted by another is a common and serious bug. The JWT validation guide lists the checks in order. Keep access tokens short-lived, minutes rather than hours, so a leaked token is useful only briefly.
Refresh tokens and rotation
A refresh token lets the client obtain new access tokens without sending the user through the browser again. It is long-lived and powerful, so it is only ever sent to the token endpoint, never to an API. For public clients, RFC 9700 requires refresh tokens to be either sender-constrained or rotated. With rotation, every refresh returns a new refresh token and invalidates the old one. If an old token is ever presented again, the server knows two parties hold the same token family and revokes all of it, which turns a silent theft into a detected one.
on refresh(request):
rt = lookup(request.refresh_token)
if rt is None or rt.expired: return error("invalid_grant")
if rt.used: # replay: someone kept an old token
revoke_family(rt.family_id) # log the user out everywhere for this client
return error("invalid_grant")
rt.used = True
new_rt = issue_refresh(family=rt.family_id, client=rt.client, scope=rt.scope)
return tokens(access=issue_access(rt), refresh=new_rt)Rotation needs care with concurrency: two browser tabs refreshing at the same moment can look like a replay. Servers usually allow a short grace period in which the previous token still works once. Clients should also call the revocation endpoint (RFC 7009) on logout, so a token stored on a shared device stops working immediately.
Sender-constrained tokens: mTLS and DPoP
A bearer token works for whoever holds it. Sender-constrained tokens bind the token to a key the legitimate client holds. With mutual TLS (RFC 8705), the server records a hash of the client certificate in the token, and the API accepts the token only over a TLS connection using that certificate; this fits service-to-service traffic where mutual TLS already exists. With DPoP (RFC 9449), the client generates a key pair and signs a small proof JWT for each request, containing the HTTP method, the URL and a timestamp; the token is bound to the public key, so a stolen token without the private key fails. DPoP works in browsers and mobile apps where client certificates are impractical.
OAuth is not login: where OpenID Connect fits
An access token tells an API what the bearer may do; it says nothing reliable to the client about who the user is. Using an access token as proof of login is a classic mistake, because a token obtained by a different client for a different purpose can be replayed into yours. OpenID Connect adds what is missing: the openid scope, an ID token that is a signed JWT addressed to the client with the user's identifier, the authentication time and a nonce, and a userinfo endpoint. Use OpenID Connect for login and OAuth access tokens for API calls. After login, how you keep the user signed in is a session question, covered in the session management guide and in JWTs versus session tokens.
Worked example: a web shop, an API and a batch job
Consider a shop with a single-page application, an orders API, and a nightly billing job.
- The SPA should not hold tokens in the browser at all if it can avoid it. A small backend for frontend, a confidential client on the same site, runs the authorization code grant with PKCE, keeps the tokens server-side and gives the browser only an HttpOnly session cookie. The browser never sees an access token, so script injection cannot steal one.
- The orders API accepts JWT access tokens with audience
orders-api, checks scopeorders.writefor changes, and caches the issuer's signing keys. Access tokens live ten minutes. - The billing job uses client credentials with private key JWT authentication and scope
invoices.write. It caches its token as in the code above. - Refresh tokens held by the backend rotate on every use, with a family revocation on replay, and logout calls the revocation endpoint.
- Redirect URIs are registered exactly, with no wildcards, and the authorization server rejects anything that does not match byte for byte.
Failure modes and trade-offs
| Failure | Consequence | Fix |
|---|---|---|
| Loose redirect URI matching | Codes delivered to an attacker's page | Exact string matching |
| Missing or unchecked state | Login CSRF: victim's session attached to attacker's account | Random state bound to the session; PKCE also helps |
| No audience check at the API | Tokens for one service accepted by another | Validate aud, iss, exp and scope on every request |
| Access token used as login proof | Account takeover via token substitution | Use OpenID Connect ID tokens with nonce |
| Long-lived bearer tokens in the browser | Script injection steals durable access | Short lifetimes, backend for frontend, DPoP |
| Tokens in logs or URLs | Leaks through proxies, analytics and referrers | Headers only; redact Authorization in logs |
| Over-broad scopes | A compromised client can do everything | Request the minimum; separate read and write |
The trade-offs are between statelessness and control, and between convenience and exposure. JWT access tokens remove a network call but delay revocation; introspection gives immediate revocation at the cost of a dependency. Longer token lifetimes reduce load and prompts but widen the window for a stolen token. Sender constraint adds client complexity and is worth it wherever tokens could leak.
What to do next
- List every OAuth client you operate, its type, its grant and its redirect URIs; remove implicit and password grants.
- Enforce PKCE for all authorization code clients and exact redirect URI matching on the authorization server.
- Verify every API checks issuer, audience, expiry and scope, not only the signature.
- Set access token lifetimes in minutes and enable refresh token rotation, or sender constraint, for public clients.
- Move browser applications to a backend for frontend pattern, or adopt DPoP where tokens must live in the client.
- Use OpenID Connect ID tokens, with nonce, for login; never treat an access token as proof of identity.
- Scrub tokens from logs and URLs, and call the revocation endpoint on logout.