OAuth 2.0 Explained for Developers: The Flows That Matter
A practical OAuth 2.0 guide for developers — authorization code flow, PKCE, client credentials, token handling, and what actually goes wrong in production implementations.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
OAuth 2.0 is one of those specifications that takes an hour to understand conceptually and months to implement correctly. The spec has multiple "flows" for different scenarios, each with its own security requirements and common mistakes. Most developers have used OAuth as a consumer (Log In With Google) but fewer have implemented it as a provider or understand why specific security measures are required.
This guide covers the flows you actually need to know, why they work the way they do, and the implementation details that separate secure from insecure.
What OAuth 2.0 Actually Does
OAuth 2.0 is an authorization framework, not an authentication protocol. The distinction matters: OAuth grants a third-party application access to a user's resources without sharing the user's credentials. Authentication (who are you?) is a separate concern, handled by OpenID Connect (OIDC), which is built on top of OAuth 2.0.
The four parties in an OAuth interaction:
Resource Owner: The user who owns the data.
Client: The application requesting access to the data.
Authorization Server: Issues tokens after authenticating the user and getting consent.
Resource Server: Hosts the user's data. Validates tokens before serving resources.
The Authorization Code Flow
This is the right flow for web applications. Never use the Implicit Flow (it has been deprecated).
The sequence:
- User clicks "Log in with Google" in your application
- Your application redirects to Google's authorization endpoint with these parameters:
response_type=codeclient_id=your-client-idredirect_uri=https://yourapp.com/callbackscope=openid email profilestate=random-csrf-value
- Google authenticates the user and shows a consent screen
- User consents; Google redirects back to your
redirect_uriwith acodeand thestatevalue - Your server exchanges the code for tokens via a server-to-server request (not in the browser)
- Google returns access token, refresh token, and ID token
// Step 2: Generate the authorization URL
function getAuthorizationUrl() {
const state = crypto.randomBytes(16).toString('hex')
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: `${process.env.APP_URL}/auth/callback`,
scope: 'openid email profile',
state,
access_type: 'offline', // Request refresh token
prompt: 'consent',
})
return {
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
state, // Store this in session to verify in callback
}
}
// Step 5: Exchange code for tokens
async function exchangeCode(code: string) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: `${process.env.APP_URL}/auth/callback`,
grant_type: 'authorization_code',
}),
})
return response.json()
}
The callback handler:
// OAuth callback handler
app.get('/auth/callback', async (c) => {
const { code, state, error } = c.req.query()
// Handle authorization errors
if (error) {
return c.redirect(`/login?error=${encodeURIComponent(error)}`)
}
// Verify state to prevent CSRF
const sessionState = await getSessionValue(c, 'oauth_state')
if (state !== sessionState) {
throw createError({ statusCode: 400, message: 'Invalid state parameter' })
}
// Exchange code for tokens
const tokens = await exchangeCode(code)
// Get user info from ID token or userinfo endpoint
const userInfo = await getUserInfo(tokens.access_token)
// Find or create user in your database
const user = await upsertUser({
email: userInfo.email,
name: userInfo.name,
avatarUrl: userInfo.picture,
googleId: userInfo.sub,
})
// Create your application session
await createSession(c, user.id)
return c.redirect('/dashboard')
})
PKCE: Required for Public Clients
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It is required for mobile apps and SPAs (public clients where you cannot keep a client secret truly secret) and recommended for all authorization code flows.
// Generate PKCE parameters before the redirect
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url')
return { verifier, challenge }
}
// Add to the authorization URL
const { verifier, challenge } = generatePKCE()
// Store verifier in session
const params = new URLSearchParams({
// ... other params
code_challenge: challenge,
code_challenge_method: 'S256',
})
// Include verifier in the token exchange
const tokenResponse = await fetch('...token_endpoint', {
body: new URLSearchParams({
// ... other params
code_verifier: verifier,
}),
})
If the authorization code is intercepted in transit, the attacker cannot exchange it for tokens without the verifier — which was never transmitted and only exists in the legitimate client's session.
Client Credentials Flow
For machine-to-machine (M2M) API communication where there is no user involved. A backend service authenticates directly with the authorization server using its client ID and secret:
async function getClientToken() {
const credentials = Buffer.from(
`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
).toString('base64')
const response = await fetch('https://auth.server.com/oauth/token', {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'read:data write:data',
}),
})
return response.json()
}
Cache the token and refresh it before expiry:
let cachedToken: { value: string; expiresAt: number } | null = null
async function getServiceToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
return cachedToken.value
}
const { access_token, expires_in } = await getClientToken()
cachedToken = {
value: access_token,
expiresAt: Date.now() + expires_in * 1000,
}
return access_token
}
Token Refresh
Access tokens expire. Refresh tokens (when provided) allow getting new access tokens without re-authenticating the user:
async function refreshAccessToken(refreshToken: string) {
const response = await fetch('https://accounts.google.com/o/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
})
if (!response.ok) {
// Refresh token expired or revoked — user must re-authenticate
throw new UnauthorizedError('Refresh token invalid')
}
return response.json()
}
Store tokens encrypted in your database. Never store them in plaintext:
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
function encryptToken(token: string): string {
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'), iv)
const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':')
}
Common Security Mistakes
Not validating the state parameter. The state parameter prevents CSRF attacks — an attacker cannot trick a user into completing an OAuth flow that hands the attacker the resulting tokens. Always generate a cryptographically random state, store it in the session, and verify it in the callback.
Using the authorization code flow without PKCE for SPAs. Public clients should always use PKCE. The client secret is not secret in a browser.
Storing tokens in localStorage. Tokens in localStorage are accessible to any JavaScript on the page, including injected scripts. Use HTTP-only cookies for refresh tokens.
Not handling token expiry gracefully. Expired tokens are a normal case, not an error. Implement silent token refresh so users do not get logged out unnecessarily.
Trusting the ID token without verifying the signature. Always verify the JWT signature using the authorization server's public keys before trusting the claims. Libraries like jose or openid-client handle this correctly.
Using a mature library like better-auth or @auth/core is the right choice for most applications. They handle these security details correctly and are maintained by people who follow OAuth security advisories. Implement OAuth from scratch only when you have specific requirements that libraries cannot meet.
Implementing OAuth for your application or need a security review of an existing auth implementation? Book a call: calendly.com/jamesrossjr.