Overview
OAuth 2.0 is the standard protocol for delegated authorization. It lets users grant an application limited access to their data on another service without sharing their password. This tutorial explains the roles, the authorization code flow with PKCE, and the mistakes that lead to real vulnerabilities.
The Four Roles
| Role | Description | Example |
|---|---|---|
| Resource owner | The user who owns the data | You |
| Client | The application requesting access | A calendar app |
| Authorization server | Issues tokens after user consent | Google Identity Platform |
| Resource server | Hosts the protected data | Google Calendar API |
Common Grant Types
| Grant | Use case | Status |
|---|---|---|
| Authorization Code + PKCE | Web apps, mobile apps, SPAs | Recommended |
| Client Credentials | Machine-to-machine, no user | Recommended |
| Device Code | Devices without a browser | Recommended |
| Implicit | Legacy SPAs | Deprecated |
| Resource Owner Password | Legacy first-party apps | Deprecated |
Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange, pronounced "pixy") protects public clients that cannot keep a secret. It replaces the client secret with a dynamically generated value.
- The client generates a random
code_verifier. - It hashes the verifier with SHA-256 to produce a
code_challenge. - The client redirects the user to the authorization endpoint with the challenge.
- After consent, the authorization server redirects back with an authorization
code. - The client exchanges the code plus the original
code_verifierfor tokens.
Step 1: Generate the PKCE Values
// Browser / Node.js
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function generatePkce() {
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64UrlEncode(verifierBytes);
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier)
);
const challenge = base64UrlEncode(digest);
return { verifier, challenge };
}
Step 2: Redirect to the Authorization Endpoint
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-client-id',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
state: crypto.randomUUID(),
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth.example.com/authorize?${params}`;
The state parameter prevents CSRF. Store it in session storage and verify it on the callback.
Step 3: Exchange the Code for Tokens
// Server-side exchange
const response = 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: receivedCode,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-client-id',
code_verifier: storedVerifier,
}),
});
const tokens = await response.json();
// {
// access_token: "...",
// refresh_token: "...",
// token_type: "Bearer",
// expires_in: 3600,
// id_token: "..."
// }
Step 4: Call the Resource Server
const profile = await fetch('https://api.example.com/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
Token Types
| Token | Purpose | Format |
|---|---|---|
| Access token | Authorizes API calls | Opaque or JWT |
| Refresh token | Obtains new access tokens without user interaction | Opaque |
| ID token | Carries user identity claims (OpenID Connect) | JWT |
Keep access tokens short-lived (5–60 minutes). Refresh tokens are long-lived and must be stored server-side or in secure HttpOnly cookies for browser clients.
Scopes and Consent
Scopes define what the client is asking for. Request the minimum set needed:
scope=openid profile email
Do not request broad scopes like drive if you only need to read a specific folder. Users are more likely to approve minimal requests, and reviewers at providers like Google will reject over-broad apps.
Where to Store Tokens
| Storage | XSS risk | CSRF risk | Verdict |
|---|---|---|---|
localStorage | High | Low | Avoid for refresh tokens |
sessionStorage | High | Low | Same as above |
| HttpOnly cookie | Low | Medium | Best for refresh tokens, add SameSite |
| In-memory (JS variable) | Low | Low | Best for access tokens in SPAs |
Security Mistakes to Avoid
- Missing
statevalidation. Enables CSRF on the callback endpoint. - Open redirect on
redirect_uri. Attackers can steal authorization codes. Always whitelist exact URIs. - Storing tokens in localStorage. Any XSS can exfiltrate them.
- Long-lived access tokens. A single leak becomes a long-term compromise.
- Trusting the
id_tokenwithout verifying the signature. Always validate against the provider's JWKS endpoint. - Using the implicit flow. Deprecated for a reason: tokens appear in URL fragments and browser history.
Tools for Testing
- oauth.tools — debug and inspect OAuth flows
- jwt.io — decode and verify JWTs
