Authorization

OAuth 2.0

OAuth 2.0 is a framework for delegated authorization, not authentication. It lets a user grant an app limited access to their data without sharing a password.

intermediate16 min readUpdated Sep 16, 2026
authorize.ts
ts
// authorize.ts
import { randomBytes, createHash } from "node:crypto";

const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");

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: randomBytes(16).toString("base64url"),
  code_challenge: challenge,
  code_challenge_method: "S256",
}).toString();

console.log(url.toString());
First spec
2012
Core RFCs
RFC 6749 / 6750
PKCE RFC
RFC 7636
Roles
4
Modern grant
Authorization Code + PKCE
Identity layer
OpenID Connect

Why it matters

What OAuth is for

Delegated access

A user grants an application limited, revocable access to their data without ever handing over a password or a long-term credential.

Scoped permissions

Each grant requests explicit scopes, so a client receives exactly the access the user consented to and nothing more.

A standard across providers

The same flow works with Google, GitHub, Okta and your own server, which means one integration pattern for every identity provider.

The big picture

The four roles, in one picture

A resource owner grants a client access to a resource server, brokered by an authorization server that issues tokens.

Resource owner

Grant

The user who owns the data and decides which application may access it, and for what.

Client

Request

The application asking for access, identified by a client id and, when it can keep one, a secret.

Authorization server

Broker

Authenticates the user, shows consent and issues access, refresh and identity tokens.

Resource server

Host

The API that holds the protected data and only returns it once it accepts a valid access token.

At a glance

The moving parts

Authorize endpoint

The browser redirect where the user authenticates and consents.

Authorization code

A short-lived, single-use code returned to the client's redirect URI.

Token endpoint

A back-channel POST that trades a code or refresh token for tokens.

Redirect URI

The registered callback, matched exactly to prevent code theft.

Resource server

The API that accepts a bearer access token and returns data.

Scopes

Space-delimited permissions that bound what a token can do.

Flow

Authorization Code + PKCE

The default flow for web, mobile and single-page apps. Two redirects and one back-channel exchange.

  1. 1

    Redirect with a challenge

    The client sends the browser to /authorize with its client id, redirect URI, scopes, state and a hashed PKCE code_challenge.

  2. 2

    The user consents

    The authorization server authenticates the user and shows the requested scopes. The user approves or denies.

  3. 3

    Redirect back with a code

    The browser returns to the registered redirect URI with a short-lived, single-use authorization code and the original state.

  4. 4

    Exchange code plus verifier

    The client POSTs the code and the original code_verifier to the token endpoint and receives access, refresh and ID tokens.

  5. 5

    Call the resource server

    The client sends the access token as a Bearer credential on API requests. The resource server validates it and enforces scopes.

  6. 6

    Refresh when it expires

    When the access token lapses, the client trades the refresh token for a new pair, then revokes tokens at logout.

A short history

From signed requests to PKCE

  1. 2007

    OAuth 1.0 and signed requests

    An early spec signs every request with shared secrets, which is secure but painful for clients and providers alike.

    07
  2. 2012

    OAuth 2.0 replaces signatures

    RFC 6749 introduces bearer tokens and the role-based model that still defines the protocol today.

    12
  3. 2014

    OpenID Connect adds identity

    OIDC layers an ID token and a userinfo endpoint on top of OAuth, enabling real sign-in.

    14
  4. 2015

    PKCE protects public clients

    RFC 7636 closes the code-interception hole for apps that cannot keep a client secret.

    15
  5. 2021

    OAuth 2.1 consolidates the lessons

    The draft deprecates implicit and password grants and makes PKCE mandatory, codifying best practice.

    21

The complete guide

OAuth 2.0: Everything you need to know

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 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 a nonce for 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, exp and nonce before 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 state on 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.

In practice

Authorize, exchange, call, refresh

The full authorization code lifecycle in four requests.

authorize.ts
import { randomBytes, createHash } from "node:crypto";

export function buildAuthorizeUrl(session: { state?: string }) {
  const verifier = randomBytes(32).toString("base64url");
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  const state = randomBytes(16).toString("base64url");

  session.state = state;
  session.codeVerifier = verifier;

  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();
}

Authorization Code + PKCE vs Implicit

PKCE keeps tokens off the URL and out of browser history, and it works for public clients that cannot hold a secret.

Prefer
// Browser is redirected, then the backend exchanges the code.
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",
  state,
  code_challenge: challenge,
  code_challenge_method: "S256",
}).toString();
Avoid
// Tokens land in the URL fragment where history,
// logs and referrers can leak them.
const url = new URL("https://auth.example.com/authorize");
url.search = new URLSearchParams({
  response_type: "token",
  client_id: "web-app",
  redirect_uri: "https://app.example.com/callback",
  scope: "openid email",
}).toString();

scopes vs roles

A scope is what the client asked for in this grant. A role is what the user is inside your system. Do not confuse the two.

Scope
// Requested capability, granted through consent.
const scope = "reports:read reports:export";

if (!req.user.scope.includes("reports:read")) {
  return res.status(403).json({ error: "insufficient_scope" });
}
Role
// Position in your own authorization model.
const role = "analyst";

// A provider scope never substitutes for a local
// role check; map scopes to roles on your server.
if (!["analyst", "admin"].includes(req.user.role)) {
  return res.status(403).json({ error: "forbidden" });
}

Trade-offs

Should you build on OAuth?

OAuth is the right tool for delegation and integration, and the wrong tool for first-party login.

Strengths

  • No password sharing

    Users grant scoped access through the provider they already trust, and can revoke it without changing their credentials.

  • One integration, many providers

    The same authorization code flow works across providers, so adding a second identity provider is mostly configuration.

  • Revocable by design

    Access and refresh tokens can be revoked, and short access-token lifetimes limit the blast radius of a leak.

Trade-offs

  • It is not authentication

    OAuth alone proves a client may access a resource, not who the user is. You need OpenID Connect for identity.

  • The footguns are sharp

    Loose redirect URIs, missing state and browser-stored tokens each lead directly to account takeover if you skip them.

  • Complexity has a cost

    Running your own authorization server means key management, consent screens, token storage and ongoing security review.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning OAuth 2.0?

Our interactive tutorial walks you through OAuth 2.0 step by step — with quizzes and real code you can run in the browser.