OAuth 2.0 Complete Guide 2026: Authorization Code, PKCE & Client Credentials Explained
OAuth 2.0 is the foundation of every “Sign in with Google” button, every GitHub integration, and every enterprise SSO you have ever used. Yet it is also one of the most misunderstood protocols in web development — confused with authentication, conflated with JWT, and implemented incorrectly in ways that create serious security vulnerabilities. This guide covers exactly what OAuth 2.0 is, how each grant type works and when to use it, a complete Authorization Code + PKCE implementation in Node.js, the client credentials flow for machine-to-machine APIs, token refresh patterns, and how OAuth relates to JWT and OpenID Connect.
4
OAuth 2.0 grant types — Authorization Code, Client Credentials, Device Code, Refresh
84%
of enterprise apps use OAuth 2.0 as their federated identity standard in 2026
PKCE
is now mandatory for all public clients — Authorization Code without PKCE is deprecated
0
passwords shared with third-party apps when OAuth is implemented correctly
Definition: What Is OAuth 2.0?
OAuth 2.0 = a delegated authorization framework — not authentication
OAuth 2.0 is a framework (RFC 6749) that lets a user grant a third-party applicationlimited access to their resources on another service — without sharing their password. When you click “Continue with GitHub” and your app reads your repos, that is OAuth 2.0. Critically: OAuth 2.0 is about authorization (what you can access), notauthentication (who you are). OpenID Connect (OIDC) is the authentication layer built on top of OAuth 2.0 — the two work together.
Four parties are involved in every OAuth 2.0 flow: the resource owner (the user), the client (your app), the authorization server (Google, GitHub, your IdP), and the resource server (the API being accessed). OAuth defines how these four parties exchange credentials safely — the user authorizes, the authorization server issues tokens, and the client uses tokens to access resources — all without the client ever seeing the user's password.
When to Use OAuth 2.0
✅ Third-party login ("Sign in with Google")
When users should log into your app using an identity they already have — Google, GitHub, Microsoft, Apple. OAuth + OIDC handles the full flow: authorization, token exchange, and user profile retrieval. Your app never handles passwords.
✅ Accessing user data on another service
Your app needs to read a user's GitHub repos, post to their Twitter, or read their Google Calendar. OAuth lets the user grant specific scopes of access without giving your app their full account credentials.
✅ Enterprise SSO and federated identity
Large organizations use a central identity provider (Okta, Azure AD, Auth0). OAuth + OIDC lets your app delegate authentication to that IdP. Users get one set of credentials across all enterprise tools.
✅ Service-to-service API access (Client Credentials)
Your backend microservice needs to call another protected API — no user involved. The Client Credentials grant handles machine-to-machine authorization: service authenticates with client_id + client_secret, gets an access token, calls the API.
❌ When you own both the client and the resource server
If your app and your API are the same organization, OAuth adds complexity without benefit. Use JWT directly, API keys, or session cookies. OAuth is designed for cross-organization authorization boundaries.
❌ Simple API key authentication
For developer-facing APIs where each developer gets a static API key, OAuth is overkill. API keys are simpler to implement, easier to rotate, and sufficient for many use cases. Use OAuth when you need delegated, scoped, time-limited access.
OAuth 2.0 vs JWT vs API Keys — Decision Chart
| Factor | OAuth 2.0 | JWT (direct) | API Keys |
|---|---|---|---|
| What it is | Authorization framework | Token format | Simple credential string |
| Purpose | Delegated access across org boundaries | Stateless auth within your system | Machine-to-machine identification |
| User involvement | User grants consent | User authenticates directly | No user — developer gets key |
| Token format | Often JWT (access_token) | Always JWT | Opaque string |
| Scopes/permissions | Fine-grained OAuth scopes | Claims in payload | All-or-nothing (or key-per-scope) |
| Revocation | ✅ Revoke at auth server | ❌ Needs blocklist | ✅ Delete key from database |
| Complexity | High — multiple parties/flows | Medium — sign/verify | Low — just a lookup |
| Best for | "Login with Google", federated SSO | Your own auth system, microservices | Developer APIs, webhooks, automation |
OAuth 2.0 access tokens are usually JWT — but they don't have to be
How OAuth 2.0 Works — The Authorization Code + PKCE Flow
Authorization Code with PKCE (Proof Key for Code Exchange, RFC 7636) is the correct grant type for any application where the client secret cannot be kept truly secret — which means all single-page apps, mobile apps, and desktop apps. As of 2026, PKCE is required for all public clients per OAuth 2.0 Security Best Practices (RFC 9700).
Authorization Code + PKCE Flow — Step by Step
Generate PKCE pair
App generates: code_verifier (32 random bytes, base64url) and code_challenge = SHA-256(code_verifier) base64url. Store code_verifier in memory.
Redirect to auth server
User clicks "Login with Google". App redirects to: /authorize?client_id=&response_type=code&code_challenge=<hash>&code_challenge_method=S256&scope=openid+email&state=<random>
User authenticates
User logs in at Google's own login page. Your app never sees their password. User approves requested scopes.
Auth code returned
Authorization server redirects back to your redirect_uri with: ?code=<auth_code>&state=<verify_state_matches>
Exchange code for tokens
POST to /token with: code, client_id, redirect_uri, code_verifier. Server verifies SHA-256(code_verifier) == code_challenge from step 1.
Tokens received
Response: { access_token, refresh_token, id_token (OIDC), expires_in, token_type: "Bearer", scope }
Call protected API
Authorization: Bearer <access_token> on every API request. Token expires in 1 hour typically.
Silent refresh
When access token expires, use refresh_token to get a new one silently. User sees no interruption.
import crypto from 'crypto';
// ── Step 1: Generate PKCE values ───────────────────────────────────────────
function generatePKCE() {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// ── Step 2: Build authorization URL ───────────────────────────────────────
function buildAuthUrl(codeChallenge, state) {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.OAUTH_CLIENT_ID,
redirect_uri: 'https://myapp.com/auth/callback',
scope: 'openid profile email',
state, // CSRF protection — verify on callback
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return 'https://accounts.google.com/o/oauth2/auth?' + params.toString();
}
// ── Login handler — redirects user to authorization server ─────────────────
app.get('/auth/login', (req, res) => {
const { codeVerifier, codeChallenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
// Store both in session — needed on callback
req.session.codeVerifier = codeVerifier;
req.session.oauthState = state;
res.redirect(buildAuthUrl(codeChallenge, state));
});
// ── Callback handler — exchanges auth code for tokens ─────────────────────
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.status(400).json({ error });
// Verify CSRF state
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'State mismatch — possible CSRF attack' });
}
// Exchange authorization code for tokens
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
redirect_uri: 'https://myapp.com/auth/callback',
code,
code_verifier: req.session.codeVerifier, // must match the challenge from step 1
}).toString(),
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
return res.status(400).json({ error: 'Token exchange failed', details: err });
}
const tokens = await tokenRes.json();
// tokens: { access_token, refresh_token, id_token, expires_in, scope }
// Store refresh token securely (HttpOnly cookie or encrypted DB column)
req.session.refreshToken = tokens.refresh_token;
req.session.accessToken = tokens.access_token;
// Fetch user profile with access token
const profile = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: 'Bearer ' + tokens.access_token },
}).then(r => r.json());
// profile: { sub, email, name, picture, email_verified }
await upsertUser({ googleId: profile.sub, email: profile.email, name: profile.name });
res.redirect('/dashboard');
});How — Client Credentials Grant (Machine-to-Machine)
The Client Credentials grant is for server-to-server communication — no user is involved. Your backend service authenticates directly with the authorization server using its own credentials (client_id + client_secret) and receives an access token scoped to the service's own permissions.
// ── Get a service access token ─────────────────────────────────────────────
// Cache this token in memory — reuse until expiry, then fetch a new one
let cachedToken = null;
let tokenExpiry = 0;
async function getServiceToken() {
if (cachedToken && Date.now() < tokenExpiry - 60_000) {
return cachedToken; // return cached token with 60s buffer
}
const res = await fetch('https://auth.myplatform.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.SERVICE_CLIENT_ID,
client_secret: process.env.SERVICE_CLIENT_SECRET,
scope: 'read:orders write:fulfillment', // only what this service needs
}).toString(),
});
if (!res.ok) throw new Error('Failed to get service token: ' + res.status);
const data = await res.json();
// { access_token, token_type: "Bearer", expires_in: 3600, scope }
cachedToken = data.access_token;
tokenExpiry = Date.now() + data.expires_in * 1000;
return cachedToken;
}
// ── Use the token to call a protected internal API ─────────────────────────
async function fetchOrder(orderId) {
const token = await getServiceToken();
const res = await fetch('https://orders-api.internal/orders/' + orderId, {
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new Error('Order API error: ' + res.status);
return res.json();
}
// No refresh_token with client credentials — just request a new access token when it expiresHow — Silent Token Refresh with Refresh Tokens
// ── Refresh access token silently ──────────────────────────────────────────
async function refreshAccessToken(refreshToken) {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
refresh_token: refreshToken,
}).toString(),
});
if (!res.ok) {
// Refresh token expired or revoked — user must re-authenticate
throw new Error('REFRESH_FAILED');
}
const data = await res.json();
// { access_token, expires_in, scope, token_type }
// Note: Google does NOT return a new refresh_token here — keep the original
// Some providers DO rotate refresh tokens — always check the response
return { accessToken: data.access_token, expiresIn: data.expires_in };
}
// ── Wrapper that auto-refreshes on 401 ────────────────────────────────────
async function oauthFetch(url, options, session) {
let res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: 'Bearer ' + session.accessToken },
});
if (res.status === 401) {
try {
const { accessToken } = await refreshAccessToken(session.refreshToken);
session.accessToken = accessToken;
// Retry with new token
res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: 'Bearer ' + session.accessToken },
});
} catch {
// Refresh failed — session expired, redirect to login
throw new Error('SESSION_EXPIRED');
}
}
return res;
}Why OAuth 2.0 — The Security Model
Users never share passwords with third-party apps
The core security property of OAuth: the user authenticates directly with the authorization server (Google's own login page). Your app only receives a time-limited, scoped access token. If your app is compromised, the attacker gets the token — not the user's Google password.
Scopes limit what a token can do
OAuth tokens are granted specific scopes: read:profile, write:calendar, read:email. A token with read:profile cannot modify calendar data. Users can see and revoke specific scope grants. Principle of least privilege is built into the protocol.
Access tokens expire — refresh tokens provide continuity
Short-lived access tokens (1 hour typical) limit the damage window of a stolen token. Long-lived refresh tokens are stored securely server-side. When access expires, the client transparently gets a new one — the user sees no interruption.
PKCE prevents authorization code interception
Without PKCE, an attacker who intercepts the authorization code (via a malicious redirect URI or browser history) can exchange it for tokens. PKCE binds the auth code to the initial request via a cryptographic challenge that only the originating client can answer.
State parameter prevents CSRF attacks
The state parameter is a random nonce generated before the redirect and verified on callback. Without it, an attacker can trick a user into completing an OAuth flow that connects the attacker's account to the victim's session. Always generate, store, and verify state.
5 OAuth 2.0 implementation mistakes that create vulnerabilities
OAuth token endpoint responses, userinfo payloads, and resource server errors all return JSON. Paste any malformed response into our AI Error Explainer for instant diagnosis.
Debug OAuth JSON →