OAuth 2.0 Explained Without the Jargon
TL;DR
OAuth 2.0 lets users grant apps access to their data without sharing passwords. Use Authorization Code flow for web apps, PKCE for SPAs and mobile, Client Credentials for server-to-server. Never store tokens in localStorage.
I spent three days implementing OAuth 2.0 before I understood what it was actually doing. The specs talk about "authorization servers," "resource owners," and "grant types" before explaining the core problem: how do you let a third-party app access user data without giving it the user's password?
Once I understood the problem, the solution made sense.
The Problem OAuth Solves
Before OAuth, third-party apps needed your actual password:
"Connect your Gmail"
→ Enter your Google username and password
→ App stores your credentials
→ App can now do ANYTHING: read email, send email, delete
→ You can't revoke access without changing password
→ If app is breached, your Google password is exposed
OAuth replaces the password with a limited-access token:
"Connect your Gmail"
→ Redirected to Google's login page (credentials never leave Google)
→ Google asks: "App X wants read-only email access. Allow?"
→ You click Allow
→ App gets a token that only allows reading email
→ You can revoke access from Google settings anytime
→ If app is breached, only that token is exposed
The Four Roles
Resource Owner = The user (you)
Client = The third-party app requesting access
Authorization Server = The service with your account (Google, GitHub)
Resource Server = The API being accessed (Gmail API, GitHub API)
In most cases: Authorization Server = Resource Server (same company)
Authorization Code Flow (Web Apps)
The main flow for server-side web applications:
User visits app
↓
App redirects to Authorization Server (Google, GitHub, etc.)
↓
User logs in + clicks "Allow"
↓
Authorization Server redirects back with a short-lived code
↓
App exchanges code + client secret for access_token (server-to-server)
↓
App uses access_token to call the API
The actual HTTP requests:
Step 1: App redirects user to authorization server
GET https://github.com/login/oauth/authorize?
response_type=code
&client_id=your_client_id
&redirect_uri=https://yourapp.com/callback
&scope=read:user,repo
&state=abc123randomstring ← CSRF protection
Step 2: User logs in on GitHub, clicks "Authorize"
GitHub redirects back:
GET https://yourapp.com/callback?
code=one_time_code
&state=abc123randomstring
// Step 3: Exchange code for token (server-side)
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify CSRF state
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state');
}
// Exchange code for token (never expose client_secret to frontend)
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: 'https://yourapp.com/callback',
}),
});
const { access_token } = await tokenResponse.json();
// Store in server-side session, not localStorage
req.session.accessToken = access_token;
res.redirect('/dashboard');
});
// Step 4: Use the token
async function getGitHubUser(accessToken) {
const response = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
return response.json();
}
Authorization Code + PKCE (SPAs and Mobile)
Single-page apps and mobile apps can't keep secrets (code is in the browser, APKs can be decompiled). PKCE (Proof Key for Code Exchange) solves this:
// PKCE: generate a verifier and a hash of it (the challenge)
function generatePKCE() {
// Random string, 43-128 characters
const verifier = crypto.randomBytes(32).toString('base64url');
// SHA256 hash sent in the initial request
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
function startOAuth() {
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
// Store in sessionStorage (not localStorage)
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: window.location.origin + '/callback',
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('Invalid state');
}
const verifier = sessionStorage.getItem('pkce_verifier');
// Exchange code + verifier for token (no client secret needed!)
const tokenResponse = 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: CLIENT_ID,
code,
redirect_uri: window.location.origin + '/callback',
code_verifier: verifier, // Proves we initiated the flow
}),
});
const { access_token, refresh_token, expires_in } = await tokenResponse.json();
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
return { access_token, refresh_token, expires_in };
}
The PKCE verifier proves the app that started the flow is the same one completing it—an intercepted code is useless without the verifier.
Client Credentials (Server to Server)
No user involved. Your backend talks to another service:
async function getServiceToken() {
const response = await fetch('https://api.service.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:data',
}),
});
return response.json();
}
// Cache tokens and refresh before expiry
class ServiceTokenCache {
constructor() {
this.token = null;
this.expiresAt = 0;
}
async getToken() {
// Refresh 1 minute before expiry
if (Date.now() < this.expiresAt - 60000) {
return this.token;
}
const { access_token, expires_in } = await getServiceToken();
this.token = access_token;
this.expiresAt = Date.now() + (expires_in * 1000);
return this.token;
}
}
const tokenCache = new ServiceTokenCache();
async function callExternalAPI(endpoint) {
const token = await tokenCache.getToken();
return fetch(`https://api.service.com/${endpoint}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
}
Refresh Tokens
Access tokens expire (typically 1 hour). Refresh tokens get new ones without re-authentication:
async function refreshAccessToken(refreshToken) {
const response = await fetch('https://accounts.google.com/o/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
throw new Error('Refresh token expired or revoked');
}
return response.json();
}
// Auto-refresh on 401
async function apiCall(url, token, refreshToken) {
let response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (response.status === 401) {
const { access_token } = await refreshAccessToken(refreshToken);
response = await fetch(url, {
headers: { 'Authorization': `Bearer ${access_token}` },
});
}
return response;
}
Common Mistakes
Storing tokens in localStorage
// BAD - any script on your page can steal it (XSS)
localStorage.setItem('access_token', token);
// GOOD - HttpOnly cookie (JavaScript can't read it)
// Server sets: Set-Cookie: session=...; HttpOnly; Secure; SameSite=Strict
// Browser sends automatically, completely opaque to JavaScript
Skipping state parameter validation
// BAD - CSRF vulnerable: attacker can trick you into linking their account
app.get('/callback', async (req, res) => {
const token = await exchangeCode(req.query.code);
});
// GOOD
app.get('/callback', async (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(403).send('Forbidden');
}
const token = await exchangeCode(req.query.code);
});
Requesting too many scopes
// BAD - requesting everything "just in case"
scope: 'read write delete admin notifications webhooks billing'
// GOOD - minimum required scopes
scope: 'read:email'
// Users approve more readily, less damage if token is compromised
Which Flow to Use
Server-side web app → Authorization Code
SPA or mobile app → Authorization Code + PKCE
Server-to-server → Client Credentials
The Bottom Line
OAuth 2.0 delegates authentication to the service that already has the user's credentials. Users never give apps their passwords. Apps get limited, revocable tokens.
The rules:
- Never put client secrets in frontend code
- Always validate the
stateparameter (CSRF protection) - Use PKCE for any public client (SPA, mobile)
- Store tokens in HttpOnly cookies, not localStorage
- Request only the scopes you actually need
- Cache Client Credentials tokens and refresh before expiry
Build it once, the pattern repeats across every provider. GitHub, Google, Slack—same flow, different endpoints.