What OAuth 2.0 is, and is not
OAuth 2.0 is an authorization framework. It lets a user grant a third-party application limited access to their resources without sharing their password. The output of a successful flow is an access token, and that token describes what the client may do, not necessarily who the user is.
That distinction trips people up constantly. “Sign in with Google” is OAuth 2.0 plus OpenID Connect. The OAuth part obtains access to Google’s APIs; the OpenID Connect part returns an ID token that actually identifies the user. If you implement only OAuth and treat the access token as proof of identity, you have built delegated access and called it authentication, which is a category error with security consequences.
The protocol was designed for a specific problem: letting a photo-printing site read your photos from a storage provider without ever seeing your storage password. Keep that origin story in mind and the design choices make sense.
OpenID Connect: identity on top
OpenID Connect is a thin identity layer over OAuth 2.0. It adds the openid scope, a standard userinfo endpoint, and most importantly an ID token, a JWT whose claims describe the authentication event.
{
"iss": "https://accounts.example.com",
"sub": "110169484474386276334",
"aud": "web-app",
"exp": 1760000000,
"iat": 1759999100,
"email": "[email protected]",
"email_verified": true,
"nonce": "n-0S6_WzA2Mj"
}
The ID token is for the client, not the API. Never send it to a resource server as an access token. Verify its signature, iss, aud, exp and nonce before trusting it, and use sub, not email, as the stable user identifier. Email addresses change and get reassigned; the subject is stable for the life of the account.
The four roles
OAuth names four participants, and being precise about them makes the rest of the protocol obvious.
- Resource owner — the user who owns the data and grants access.
- Client — the application requesting access, for example your web app.
- Authorization server — issues tokens after authenticating the user and obtaining consent.
- Resource server — the API that accepts the access token and returns data.
Your web app is the client. Google is the authorization server. Google’s APIs are the resource server. The user is the resource owner. A single provider often plays both server roles, which is why the distinction can feel academic until you build your own.
Grant types
A grant type is the recipe a client uses to obtain a token. Four matter today.
- Authorization Code + PKCE — the default for web, mobile and single-page apps. The user authenticates at the authorization server, and the client exchanges a short-lived code for tokens.
- Client Credentials — machine-to-machine. No user is involved; the client authenticates with its own credentials.
- Device Code — for input-constrained devices such as televisions and command-line tools.
- Refresh Token — not a way to log in, but the standard way to renew an access token without another redirect.
Two grants are effectively dead. The implicit flow returned tokens directly in the URL fragment, exposing them to history and referrers. The password grant asked the client to handle the user’s password, which defeats the entire point of OAuth. OAuth 2.1 removes both, and no new system should use them.
Authorization Code + PKCE
This is the flow to learn. The client redirects the user to the authorization server with a hashed challenge, the user consents, the server redirects back with a one-time code, and the client exchanges that code plus the original verifier for tokens.
The code is useless without the verifier, so an attacker who intercepts the redirect cannot complete the exchange. That is what makes PKCE safe even for public clients, such as mobile apps and single-page apps, that cannot keep a secret at all.
The flow has two browser redirects and one back-channel request. The redirects are visible and can be tampered with; the exchange is a direct server-to-server POST that an attacker cannot observe. Keeping the secret material on the back channel is the whole design.
The redirect URI and state
The redirect URI is where the authorization server sends the user back. It must be registered exactly and matched exactly. Loose matching is the source of open-redirect vulnerabilities that leak authorization codes to attacker-controlled hosts. Never accept a redirect URI from a query parameter, and do not allow wildcard subdomains.
The state parameter is an opaque value the client generates and checks when the callback returns. It defends the callback endpoint against CSRF. An attacker who tricks a victim into completing a flow with the attacker’s code will fail the state check.
const state = randomBytes(16).toString("base64url");
session.oauthState = state;
// later, in the callback:
if (query.state !== session.oauthState) {
throw new Error("state_mismatch");
}
For OIDC, add a nonce and verify it inside the ID token. State protects the client’s callback from CSRF; the nonce binds the ID token to this specific request and blocks replay.
PKCE in detail
PKCE, Proof Key for Code Exchange, is defined by RFC 7636 and is now mandatory for public clients. It is three values. The client generates a random code_verifier, derives a code_challenge as its SHA-256 hash, sends the challenge on the authorize request, and sends the verifier on the token request. The server hashes the verifier and compares.
import { randomBytes, createHash } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
Always use the S256 method. The plain method exists only for compatibility and defeats the purpose, since an attacker who sees the challenge sees the verifier. Store the verifier with the user’s session so the callback can find it, and delete it after a single use.
Building the authorize request
The authorize request is a browser redirect, so it is a GET with query parameters. The user sees the provider’s consent screen, not your application.
export function buildAuthorizeUrl(state: string, challenge: string) {
const url = new URL("https://auth.example.com/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: "web-app",
redirect_uri: "https://app.example.com/callback",
scope: "openid email profile offline_access",
state,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
return url.toString();
}
response_type=code selects the authorization code flow. The openid scope turns the request into an OIDC request. offline_access is the common convention for asking for a refresh token, and some providers gate it behind an extra consent prompt.
Token exchange
The callback delivers code and state. After checking state, the client POSTs the code to the token endpoint. This is a back-channel call from your server, not a browser redirect.
export async function exchangeCode(code: string, verifier: string) {
const res = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://app.example.com/callback",
client_id: "web-app",
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error("token_exchange_failed");
return res.json();
}
The response contains access_token, token_type, expires_in, usually refresh_token, and for OIDC an id_token. A confidential client, one that can hold a secret, also authenticates on this request with client_secret or, better, a private-key assertion. A public client relies on PKCE alone.
Access, refresh and ID tokens
The three tokens have different jobs, and mixing them up causes subtle bugs.
- Access token — presented to the resource server. It is often a JWT, but the specification only requires that it be opaque to the client. It may be a random string that the server looks up.
- Refresh token — presented only to the authorization server to obtain a new access token. Long-lived and highly sensitive.
- ID token — an OIDC JWT that tells the client who just logged in. It is never sent to an API.
Treat the access token as a bearer credential: anyone holding it can use it. Keep lifetimes short, request the narrowest scopes you need, and let the refresh token carry the long-lived relationship. The token format itself is covered in the JWT guide.
Scopes and consent
Scopes express what the client is asking for. The authorization server shows them to the user as a consent screen and encodes the granted subset into the access token.
Request the minimum. A calendar app that asks for full mailbox access will scare users away and widen the blast radius of a breach. Providers also publish reserved scopes: openid is required for OIDC, and offline_access usually controls refresh tokens.
Scopes are not roles. A scope describes a capability the client requested for this grant; a role describes what the user is within your system. Map between them on your server, and never assume a provider’s scopes say anything about your own authorization model. For that side of the problem, see RBAC.
Calling the resource server
With an access token in hand, calls to the API carry it in the Authorization header using the Bearer scheme.
const res = await fetch("https://api.example.com/me", {
headers: { authorization: `Bearer ${accessToken}` },
});
The resource server validates the token, either by verifying a JWT locally or by introspecting it, checks the scope, and returns the data. It never sees the refresh token or the ID token, and it should reject them if it does.
Introspection and revocation
Not every access token is a JWT. When it is opaque, the resource server asks the authorization server whether it is still valid using token introspection, defined by RFC 7662.
POST /introspect HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <client credentials>
token=2YotnFZFEjr1zCsicMWpAA
Introspection returns active, plus the scope, subject and expiry. It is authoritative but costs a network call, so cache the result for a few seconds.
Revocation, defined by RFC 7009, lets a client tell the authorization server to invalidate a token, usually at logout.
await fetch("https://auth.example.com/revoke", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken, client_id: "web-app" }),
});
Revoke refresh tokens aggressively on logout, and remember that revoking a refresh token does not always invalidate already-issued access tokens. Short access-token lifetimes are what make revocation feel immediate.
Machine-to-machine: client credentials
When no user is involved, the client acts as itself. It authenticates to the token endpoint and receives an access token scoped to its own permissions.
const res = await fetch("https://auth.example.com/token", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "reports:read",
}),
});
There is no consent screen and no refresh token; the service simply requests a new token when the old one expires. Prefer private-key JWT client authentication over a shared secret where the provider supports it, and rotate secrets on a schedule. This is the same niche that long-lived API keys occupy, but with standard expiry and scoping, as covered in API Keys.
When not to use OAuth
OAuth is for delegating access to a third party. If your own web app is authenticating its own users against its own backend, you do not need OAuth. A session cookie, or a first-party JWT you issue yourself, is simpler and easier to secure. Putting an authorization server between your login form and your database buys complexity, not safety.
Reach for OAuth when you integrate an external provider, act as a provider for third-party clients, or need a standard for machine-to-machine access. Otherwise start with Session Auth and add OAuth only when a genuine delegation need appears. Because every flow here runs over redirects and headers, the HTTP guide is a useful companion.
Common pitfalls
- Implicit flow — tokens in the URL fragment leak through history, logs and referrers. Use Authorization Code with PKCE.
- Tokens in localStorage — an XSS bug becomes account takeover. Keep tokens in httpOnly cookies or server-side sessions.
- Open redirects — loose redirect URI matching lets an attacker steal codes. Match the registered URI exactly.
- Skipping state — without it, the callback is vulnerable to CSRF.
- Over-broad scopes — requesting everything makes consent meaningless and breaches worse.
- Long-lived access tokens — they cannot be revoked quickly. Keep them short and rotate refresh tokens.
- Using the ID token as an API credential — it is for the client, not the resource server.
Choosing the right flow
Most integration decisions collapse to two questions: is there a user, and can the client keep a secret?
- A user is present, the client is public (SPA, mobile app, desktop): Authorization Code with PKCE.
- A user is present, the client is confidential (server-rendered web app): Authorization Code with PKCE plus client authentication.
- No user, the client is a service (cron job, microservice): Client Credentials.
- The device has no browser or keyboard (TV, CLI): Device Code.
Everything else is legacy. If a provider only documents the implicit flow, treat it as a warning sign and check whether PKCE is available.
The backend-for-frontend pattern
The safest place to hold tokens is a server you control. The backend-for-frontend pattern puts a thin server between the browser and the authorization server: the browser gets a session cookie, and the server holds the access and refresh tokens.
app.get("/callback", async (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: "state_mismatch" });
}
const tokens = await exchangeCode(
String(req.query.code),
req.session.codeVerifier,
);
req.session.tokens = tokens;
delete req.session.oauthState;
delete req.session.codeVerifier;
res.redirect("/dashboard");
});
The browser never sees a token, so XSS cannot steal one, and the server can refresh silently. The trade-off is an extra hop and a server to run, which is usually cheaper than the incident you avoid.
Native and mobile clients
Native apps cannot keep a client secret, so PKCE is not optional. They also have a redirect problem: a custom scheme such as myapp://callback can be claimed by a malicious app. The modern answer is a system browser tab, either the platform’s authentication session API or an embedded browser that shares cookies with the system.
Never use an embedded WebView for OAuth. The app can read the user’s password, which breaks the trust boundary the whole protocol exists to protect. Open the system browser, receive the callback, and keep tokens in the platform’s secure storage.
Running your own authorization server
You do not have to build one. Established servers such as Keycloak, Ory Hydra and cloud identity providers implement the protocol, consent screens, key management and token storage for you. Building your own is a multi-month project whose failure modes are all security critical.
If you do run your own, the minimum responsibilities are: exact redirect URI matching, PKCE enforcement, short access-token lifetimes, refresh-token rotation with reuse detection, a JWKS endpoint, and revocation. Miss any one and you have shipped a vulnerability, not a feature.
Testing an OAuth integration
End-to-end OAuth tests are slow and brittle because they depend on a real provider and a real browser. Test in layers instead.
- Unit-test URL construction, PKCE derivation and state comparison as pure functions.
- Integration-test the token exchange against a mock authorization server that returns canned tokens.
- Keep one smoke test against the real provider, marked so it does not run on every commit.
test("builds an authorize URL with PKCE", () => {
const url = new URL(buildAuthorizeUrl("state-1", "challenge-1"));
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("state")).toBe("state-1");
});
The callback is where bugs hide, so test the failure paths explicitly: a mismatched state, a replayed code, and an expired token should each produce a clear error rather than a half-authenticated session.
Logout and single sign-on
Logging out of your application is not the same as logging out of the provider. A complete logout does three things: it destroys the local session, revokes the refresh token at the authorization server, and, for single sign-on, ends the provider session so the next login does not silently reuse it.
app.post("/logout", async (req, res) => {
if (req.session.tokens?.refresh_token) {
await revoke(req.session.tokens.refresh_token);
}
req.session.destroy(() => res.redirect("/"));
});
Single sign-on falls out of the same machinery: once a user has a session at the provider, subsequent clients receive a code without re-entering a password. That is convenient, and it is also why shared-computer logout matters more than people expect.
Refresh token rotation and reuse detection
OAuth does not require refresh tokens to be single-use, but the strongest implementations rotate them. Every refresh returns a new refresh token and invalidates the previous one. If an old token is presented again, the server knows it was stolen and revokes the whole family.
export async function refresh(grant: { refresh_token: string; client_id: string }) {
const stored = await db.refreshToken.findByHash(hash(grant.refresh_token));
if (!stored || stored.revoked) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_grant");
}
await db.refreshToken.revoke(stored.id);
return issueTokens(stored.userId, stored.familyId);
}
Reuse detection is the payoff: a stolen refresh token becomes a tripwire rather than a permanent backdoor. Pair it with a sliding expiry so an active user stays signed in while an abandoned token eventually dies.
Best practices
- Use Authorization Code with PKCE for every user-facing client, including SPAs and mobile.
- Use Client Credentials for machine-to-machine, with private-key authentication where possible.
- Register exact redirect URIs and reject anything that does not match character for character.
- Always send and verify
state; add and verify anoncefor OIDC. - Request the minimum scopes and treat the consent screen as meaningful.
- Keep access tokens short-lived, rotate refresh tokens, and revoke on logout.
- Verify the ID token’s signature,
iss,aud,expandnoncebefore trusting it. - Keep client secrets and private keys in a secret manager, never in the frontend.
- Cache introspection briefly and rely on short lifetimes for timely revocation.
Common mistakes
- Treating OAuth as authentication without OpenID Connect.
- Shipping the implicit flow because it is one redirect shorter.
- Storing access tokens in localStorage and sending them to every origin.
- Putting the client secret in a single-page app where anyone can read it.
- Accepting any redirect URI, or one supplied in a query parameter.
- Forgetting to validate
stateon the callback. - Requesting every scope and never revisiting the list.
- Assuming revoking a refresh token instantly kills access tokens.
- Using the ID token as a bearer token for your API.
Where to go next
OAuth is how tokens are obtained; JWT explains how they are built and verified. If your app is first-party and you want immediate revocation, Session Auth is the simpler starting point. For machine clients that never involve a user, API Keys covers the long-lived alternative. And because every one of these flows runs over the wire, HTTP is worth a refresher.