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.

Advertisement

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.

Authorization serverResource ownerthe user in a browserAuthorization endpointlogin and consentClientweb app backendToken endpointissues tokensResource serverthe API1 clicks login2 /authorize3 log in and consent4 code5 code, verifier6 tokens7 Bearer access tokenThe code travels through the browser; the tokens travel only on back channels
The authorization code grant: the browser carries only a short-lived code, the client redeems it at the token endpoint with its own credentials and PKCE verifier, and the API receives an access token.

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

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

Advertisement

Choosing a grant

SituationGrantNotes
User signs in to a web, mobile or single-page appAuthorization code with PKCEPKCE for every client type, public or confidential
Service calls another service as itselfClient credentialsNo user; scopes describe what the service may do
TV, CLI or device with no browser or keyboardDevice authorization grant (RFC 8628)User approves on a second device
Renewing access without the userRefresh tokenRotate or sender-constrain for public clients
Legacy: token in the redirect URLImplicitRFC 9700 says it SHOULD NOT be used
Legacy: app collects the passwordResource owner password credentialsRFC 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 introspectionJWT access token
ValidationNetwork call per token, usually cachedLocal signature and claim checks
RevocationImmediateEffective only at expiry, unless you add a deny list
PrivacyContents hidden from the clientClaims readable by anyone holding the token
AvailabilityAPI depends on the introspection endpointAPI 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.

  1. 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.
  2. The orders API accepts JWT access tokens with audience orders-api, checks scope orders.write for changes, and caches the issuer's signing keys. Access tokens live ten minutes.
  3. The billing job uses client credentials with private key JWT authentication and scope invoices.write. It caches its token as in the code above.
  4. Refresh tokens held by the backend rotate on every use, with a family revocation on replay, and logout calls the revocation endpoint.
  5. 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

FailureConsequenceFix
Loose redirect URI matchingCodes delivered to an attacker's pageExact string matching
Missing or unchecked stateLogin CSRF: victim's session attached to attacker's accountRandom state bound to the session; PKCE also helps
No audience check at the APITokens for one service accepted by anotherValidate aud, iss, exp and scope on every request
Access token used as login proofAccount takeover via token substitutionUse OpenID Connect ID tokens with nonce
Long-lived bearer tokens in the browserScript injection steals durable accessShort lifetimes, backend for frontend, DPoP
Tokens in logs or URLsLeaks through proxies, analytics and referrersHeaders only; redact Authorization in logs
Over-broad scopesA compromised client can do everythingRequest 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

  1. List every OAuth client you operate, its type, its grant and its redirect URIs; remove implicit and password grants.
  2. Enforce PKCE for all authorization code clients and exact redirect URI matching on the authorization server.
  3. Verify every API checks issuer, audience, expiry and scope, not only the signature.
  4. Set access token lifetimes in minutes and enable refresh token rotation, or sender constraint, for public clients.
  5. Move browser applications to a backend for frontend pattern, or adopt DPoP where tokens must live in the client.
  6. Use OpenID Connect ID tokens, with nonce, for login; never treat an access token as proof of identity.
  7. Scrub tokens from logs and URLs, and call the revocation endpoint on logout.
Key takeaway: OAuth 2.0 issues short-lived, scoped tokens so applications can act on a user's or their own behalf without handling passwords. Use the authorization code grant with PKCE for users, client credentials for services and the device grant for devices without a browser, and retire implicit and password grants. Validate issuer, audience, expiry and scope at every API, rotate or sender-constrain refresh tokens, keep tokens out of browsers where possible, and use OpenID Connect for login.