Web
OAuth 2.0
Let an app act on your behalf — without ever handing it your password
OAuth 2.0 is a delegated authorization framework that lets an app act on your behalf by exchanging a short-lived authorization code for an access token — so it never sees your password.
- SpecificationRFC 6749 (2012)
- Recommended flowAuth Code + PKCE
- Access token lifetime5–60 min
- Roles4 (owner, client, AS, RS)
- Token transportAuthorization: Bearer
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
The problem OAuth solves
You want a photo-printing site to grab the pictures from your Google Photos. The naive answer — type your Google password into the printing site — is a disaster. Now an unrelated company stores your master credential, can read your email, can change your password, and you can't revoke its access without changing your password everywhere. This actually happened in the 2000s: services asked for your webmail password to "find your friends," and they got the keys to your entire digital life.
OAuth 2.0, standardized as RFC 6749 in October 2012, fixes this with delegated authorization. Instead of giving the printing site your password, you send it to Google. Google asks you: "PrintCo wants read-only access to your photos — allow?" If you say yes, Google hands PrintCo a narrow, revocable, expiring access token. The token can read photos and nothing else. PrintCo never sees your password, and you can revoke the token from your Google account page at any time, instantly, without touching your password.
There are four roles in every OAuth flow:
- Resource owner — you, the human who owns the photos.
- Client — the app that wants access (PrintCo).
- Authorization server (AS) — the party that authenticates you and issues tokens (Google's OAuth endpoint).
- Resource server (RS) — the API holding the data, which accepts the token (the Google Photos API).
The Authorization Code flow with PKCE
The recommended flow — and the only one OAuth 2.1 keeps for browser and mobile apps — is Authorization Code with PKCE. The key idea is to split the exchange across two channels. The front channel is the browser redirect, which is visible in URLs, history, and logs. The back channel is a direct server-to-server HTTPS call, which nobody else can see. The secret (the token) only ever crosses the back channel.
- Generate a PKCE pair. The client creates a random
code_verifier(43–128 chars) and computescode_challenge = BASE64URL(SHA-256(code_verifier)). - Redirect to /authorize. The client sends the browser to the AS with its
client_id, requestedscope, aredirect_uri, an anti-CSRFstate, and thecode_challenge. - User consents. The AS authenticates the user and shows the consent screen ("PrintCo wants read access to your photos").
- Code returns on the front channel. The AS redirects the browser back to
redirect_uri?code=...&state=.... This code is single-use and expires in seconds. - Exchange on the back channel. The client POSTs the
codeplus the originalcode_verifierto the AS's/tokenendpoint. The AS hashes the verifier and checks it matches the challenge it saw in step 2. - Tokens issued. The AS returns an
access_tokenand usually arefresh_token. - Call the API. The client sends
Authorization: Bearer <access_token>to the resource server on every request.
The genius of PKCE: even if an attacker intercepts the authorization code as it bounces through the browser, they can't redeem it. Redeeming requires the code_verifier, which never left the client and which a SHA-256 preimage attack can't recover from the challenge.
The grant types (and which to use)
A "grant type" is the strategy for obtaining a token. OAuth 2.0 defined several; OAuth 2.1 prunes the unsafe ones.
- Authorization Code (+ PKCE) — for any app acting for a user: web servers, single-page apps, mobile, desktop. The default. Use this.
- Client Credentials — no user at all; one server authenticating as itself to call another (cron jobs, microservice-to-microservice). The client sends its ID and secret straight to
/token. - Refresh Token — not a login flow; it trades a refresh token for a fresh access token when the old one expires.
- Device Authorization (RFC 8628) — for input-constrained devices (smart TVs, CLIs). The device shows a code and a URL; you authorize on your phone.
- Implicit — deprecated. Returned the token in the URL fragment. Removed in OAuth 2.1.
- Resource Owner Password Credentials (ROPC) — deprecated. The app collects your username and password directly, defeating OAuth's entire purpose. Removed in OAuth 2.1.
OAuth flows compared
| Auth Code + PKCE | Client Credentials | Device Code | Implicit (dead) | ROPC (dead) | |
|---|---|---|---|---|---|
| User present? | Yes | No (app is the actor) | Yes (second device) | Yes | Yes |
| Client type | Any (public or confidential) | Confidential only | Input-constrained | Browser SPA | Trusted first-party |
| Token crosses browser? | No (back channel) | No | No | Yes (URL fragment) | No |
| Refresh token? | Yes | No (just re-request) | Yes | No | Yes |
| Sees user password? | Never | N/A | Never | Never | Yes — defeats OAuth |
| PKCE | Required | N/A | Recommended | N/A | N/A |
| OAuth 2.1 status | Recommended | Kept | Kept | Removed | Removed |
The headline shift from OAuth 2.0 to the OAuth 2.1 consolidation: anything that put a long-lived secret on the front channel is gone, and PKCE went from "mobile best practice" to "everyone, always."
What the numbers actually say
- One extra round trip. Auth Code adds exactly one back-channel POST (the code-for-token exchange) over the redirect. On a typical 50–150 ms server-to-server hop, that's a one-time cost per login, not per API call.
- Access tokens live 5–60 minutes. Google defaults to 3600 s (1 hour); many providers use 5–15 minutes. A leaked access token is therefore useful for minutes, not forever.
- PKCE costs one SHA-256. The
code_challengeis a single hash of a ~43-byte string — microseconds — and it closes the entire class of authorization-code interception attacks. - JWT access tokens save a database lookup per request. A self-contained JWT lets the resource server validate offline by checking a signature (one RSA/ECDSA verify, ~0.1–1 ms) instead of a round trip to the AS's introspection endpoint (tens of ms). The trade-off is you can't revoke it mid-life.
- The
stateparameter is 1 value that blocks CSRF. Omitting it has caused real account-takeover bugs; including a 16+ byte random value and verifying it on return is the entire fix.
JavaScript implementation
A browser-side single-page app generating the PKCE pair and starting the flow, then exchanging the code:
// --- PKCE helpers (run in the browser) ---
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function makePkcePair() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest(
'SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
return { verifier, challenge };
}
const CFG = {
authUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
clientId: 'printco-spa',
redirect: 'https://printco.app/callback',
scope: 'photos.read',
};
// Step 1–2: kick off the redirect
async function login() {
const { verifier, challenge } = await makePkcePair();
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const q = new URLSearchParams({
response_type: 'code',
client_id: CFG.clientId,
redirect_uri: CFG.redirect,
scope: CFG.scope,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location = `${CFG.authUrl}?${q}`;
}
// Step 5–6: handle the redirect back, exchange code for tokens
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('oauth_state'))
throw new Error('state mismatch — possible CSRF'); // critical check
const res = await fetch(CFG.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: CFG.redirect,
client_id: CFG.clientId,
code_verifier: sessionStorage.getItem('pkce_verifier'),
}),
});
const { access_token, refresh_token, expires_in } = await res.json();
return { access_token, refresh_token, expires_in };
}
// Step 7: call the API
async function getPhotos(accessToken) {
const r = await fetch('https://api.example.com/photos', {
headers: { Authorization: `Bearer ${accessToken}` },
});
return r.json();
}
Two details that bite people. First, the state check is not optional — skipping it reopens a CSRF hole that lets an attacker graft their own authorization onto your session. Second, a public client (an SPA) has no client secret to send; PKCE is what proves the token request came from the same client that started the flow.
Python implementation
The server side of a confidential web-app client, using Flask and requests:
import os, base64, hashlib, secrets
from urllib.parse import urlencode
import requests
from flask import Flask, redirect, request, session
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
AUTH_URL = "https://auth.example.com/authorize"
TOKEN_URL = "https://auth.example.com/token"
CLIENT_ID = "printco-web"
CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"] # confidential client
REDIRECT = "https://printco.app/callback"
SCOPE = "photos.read"
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
@app.route("/login")
def login():
verifier = b64url(secrets.token_bytes(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state = b64url(secrets.token_bytes(16))
session["pkce_verifier"] = verifier
session["oauth_state"] = state
params = urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT,
"scope": SCOPE,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
})
return redirect(f"{AUTH_URL}?{params}")
@app.route("/callback")
def callback():
if request.args.get("state") != session.get("oauth_state"):
return "state mismatch", 400 # CSRF guard
code = request.args["code"]
resp = requests.post(TOKEN_URL, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET, # back channel only
"code_verifier": session["pkce_verifier"],
}, timeout=10)
resp.raise_for_status()
tokens = resp.json()
session["access_token"] = tokens["access_token"]
session["refresh_token"] = tokens.get("refresh_token")
return "Authorized — token stored server-side."
def refresh(refresh_token: str) -> dict:
"""Trade a refresh token for a new access token (no user prompt)."""
resp = requests.post(TOKEN_URL, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}, timeout=10)
resp.raise_for_status()
return resp.json()
Note the asymmetry with the SPA: a confidential server-side client does have a client_secret and sends it on the back channel. Modern guidance is to use PKCE here too — defense in depth — even though the secret already authenticates the client.
Variants and related standards worth knowing
OpenID Connect (OIDC). OAuth answers "what can this app do?"; it does not answer "who is the user?". OIDC is a thin layer on top that adds a signed id_token (a JWT with the user's identity) and a /userinfo endpoint. "Sign in with Google" is OIDC, not raw OAuth.
OAuth 2.1. A consolidation, not a rewrite: it folds in the security best-current-practice RFCs, makes PKCE mandatory, removes the Implicit and Password grants, and bans bearer tokens in query strings.
JWT vs opaque tokens. An access token can be a self-contained signed JWT (validate offline, can't revoke) or an opaque random string the RS introspects against the AS (RFC 7662 — revocable, but a network hop per check). It's a latency-vs-revocability trade-off.
DPoP and mTLS sender-constrained tokens. Plain Bearer tokens are "whoever holds it, wins." DPoP (RFC 9449) and mutual-TLS binding cryptographically tie a token to a specific client key, so a stolen token is useless to the thief.
PAR and JAR. Pushed Authorization Requests (RFC 9126) and JWT-Secured Authorization Requests move the request parameters off the front-channel URL into a back-channel POST or a signed JWT, hardening high-security deployments like open banking (FAPI).
Common bugs and edge cases
- Skipping the
statecheck. Without verifyingstateon the callback, an attacker can fixate their own authorization code into your session (login CSRF). Always generate it, store it, and compare on return. - Loose
redirect_urimatching. The AS must match the redirect URI exactly. Allowing wildcards or open redirects lets an attacker steal the authorization code by redirecting it to their own site. - Putting a client secret in a public client. SPAs and mobile apps can't keep a secret — anyone can decompile the app or read the JS bundle. Use PKCE and treat the client as public; never ship a secret in browser or app code.
- Confusing scopes with permissions. A scope is what the user consented to delegate; it does not override the resource server's own access control. The RS must still check that the user actually owns the resource.
- Treating OAuth as authentication. "Log in with OAuth" by calling an API and assuming the user is whoever the token belongs to is the classic confused-deputy bug. Use OIDC's
id_tokenfor identity. - Long-lived or non-rotating refresh tokens. A leaked refresh token that never rotates is a permanent backdoor. Rotate refresh tokens on each use and detect reuse of an already-rotated token as a breach signal.
- Bearer tokens in URLs or logs. Putting an access token in a query string leaks it into server logs, browser history, and referer headers. Send it only in the
Authorizationheader.
Frequently asked questions
Is OAuth 2.0 authentication or authorization?
Authorization. OAuth 2.0 grants an app scoped access to resources — it answers "what is this app allowed to do?", not "who is the user?". For authentication (proving identity), use OpenID Connect, which is a thin identity layer built on top of OAuth that adds a signed ID token.
What is PKCE and why is it now required?
PKCE (Proof Key for Code Exchange, RFC 7636) binds an authorization code to the client that requested it. The client sends a SHA-256 hash of a random secret (the code_challenge) up front, then proves it knows the secret (the code_verifier) when redeeming the code. It stops an attacker who intercepts a code from redeeming it. The OAuth 2.1 draft makes PKCE mandatory for all clients, not just mobile and single-page apps.
What's the difference between an access token and a refresh token?
An access token is short-lived (typically 5–60 minutes) and sent on every API call as a Bearer token. A refresh token is long-lived (days to months), kept secret, and used only to mint new access tokens when the old one expires — without re-prompting the user. If an access token leaks it expires fast; if a refresh token leaks the damage is much larger, so refresh tokens should be rotated and bound to the client.
Why is the Implicit flow deprecated?
The Implicit flow returned the access token directly in the URL fragment, where it leaked through browser history, referrer headers, and logs, and could not be refreshed safely. OAuth 2.1 removes it entirely. Single-page apps should now use the Authorization Code flow with PKCE instead.
Why does OAuth use a one-time authorization code instead of returning the token straight away?
The code travels through the browser's front channel, which is visible in URLs and logs; the token is exchanged on the secure back channel, server to server. Splitting it this way means the value that crosses the untrusted channel (the code) is single-use, short-lived, and useless without the client's credential or PKCE verifier.
Can OAuth tokens be revoked before they expire?
Yes for refresh tokens and for opaque (reference) access tokens — the authorization server checks them against a store on every use (RFC 7009 defines a revocation endpoint). Self-contained JWT access tokens are the hard case: a resource server validating a JWT offline won't know it was revoked until it expires, which is why JWT access tokens are kept short-lived or paired with a token-introspection or denylist check.