Skip to content
C Codeloom
Backend

OAuth2 Explained: Flows, Tokens, and Real Examples

OAuth2 demystified for working developers: the four flows, access and refresh tokens, PKCE, scopes, and where security actually breaks in practice.

·4 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • Tell the OAuth2 flows apart and pick the right one
  • Understand access tokens, refresh tokens, and scopes
  • Implement Authorization Code with PKCE in Node
  • Recognize common OAuth2 vulnerabilities
  • Decide between OAuth2, JWT, and session cookies

Prerequisites

  • Comfort with HTTP and JSON
  • Read [What is REST](/blog/what-is-rest)
  • Optional: [What is Node.js](/blog/what-is-nodejs)

OAuth2 is not a login system. It is a delegation protocol that lets a user authorize one app to act on their behalf at another. The spec is small. The misconceptions are not.

The four roles

  • Resource owner: the user.
  • Client: the app asking for access.
  • Authorization server: issues tokens after consent.
  • Resource server: the API that accepts tokens.

Everything below is a dance between these four.

Authorization Code with PKCE

This is the default flow for everything: web apps, SPAs, mobile, CLIs. The shape is:

  1. Client redirects the user to the auth server with a code_challenge.
  2. User logs in and consents.
  3. Auth server redirects back with a one-time code.
  4. Client exchanges code plus code_verifier for an access token.

PKCE binds the code to the original client by requiring proof of the verifier. Even if the code leaks, an attacker without the verifier cannot redeem it.

import crypto from "node:crypto";

function pkce() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

const { verifier, challenge } = pkce();
const url = new URL("https://auth.example.com/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", "my-app");
url.searchParams.set("redirect_uri", "https://app.example.com/cb");
url.searchParams.set("scope", "read:profile read:orders");
url.searchParams.set("state", crypto.randomBytes(16).toString("hex"));
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
console.log(url.toString());

After the user returns to redirect_uri, exchange the code:

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/cb",
    client_id: "my-app",
    code_verifier: verifier,
  }),
});
const tokens = await res.json();

You get an access_token, optionally a refresh_token, an expires_in value, and the granted scope.

Client Credentials

Machine-to-machine. The client authenticates itself, no user involved.

curl -X POST https://auth.example.com/token \
  -d grant_type=client_credentials \
  -d client_id=service-a \
  -d client_secret=*** \
  -d scope=read:metrics

Use this for backend cron jobs and service-to-service calls. Never embed client secrets in browsers or mobile binaries.

Device Code

For TVs, CLIs, and anything without a real keyboard. The device shows a short code; the user opens a URL on a phone and approves. Useful pattern for developer tooling.

Resource Owner Password (deprecated)

Avoid. It hands the user password to the client. The OAuth 2.1 draft removes it.

Tokens

An access token is a credential the client sends to the resource server, usually as Authorization: Bearer <token>. It may be opaque (look it up at the auth server) or a JWT (verify it locally). See JWT Authentication Explained.

A refresh token is longer-lived and used to mint new access tokens without bothering the user. Refresh tokens must be stored securely, rotated on use, and revocable.

Scopes

Scopes name permissions. The client requests them, the auth server may grant fewer, and the resource server enforces them. Design scopes around verbs and resources: read:orders, write:invoices, not admin.

Validating tokens

A resource server can introspect opaque tokens:

const r = await fetch("https://auth.example.com/introspect", {
  method: "POST",
  headers: { authorization: "Basic " + btoa("rs:rs-secret") },
  body: new URLSearchParams({ token: accessToken }),
});
const info = await r.json();
if (!info.active) throw new Error("invalid");

Or verify a JWT signature using JWKS:

import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const { payload } = await jwtVerify(token, jwks, {
  issuer: "https://auth.example.com",
  audience: "api.example.com",
});

Validate issuer, audience, expiry, and scopes. Never trust a token because it parses.

Where OAuth2 breaks

  • Open redirects on redirect_uri. Always exact-match registered URIs.
  • Missing state parameter, leading to CSRF on the callback.
  • Storing refresh tokens in localStorage. Use http-only secure cookies or a backend session.
  • Mixing up id tokens and access tokens. Id tokens are for the client, access tokens are for the API.
  • Confusing OAuth2 with authentication. For login you want OpenID Connect, which sits on top of OAuth2.

OAuth2 vs JWT vs sessions

OAuth2 is a protocol, JWT is a token format, sessions are server-side state. They are not alternatives, they are layers. Many OAuth2 deployments issue JWT access tokens and back the auth server with sessions.

Wrap up

Use Authorization Code with PKCE for almost everything, Client Credentials for service-to-service, and never bypass the spec because it feels easier. The flows are guard rails written in blood.