OAuth 2.0 Flows Demystified
Authorization Code, PKCE, Client Credentials — which OAuth flow to use for which application type, and the security pitfalls hiding in each one.

OAuth 2.0 is the standard for delegated authorization, but its specification is confusingly flexible. Multiple grant types, optional parameters, and vendor-specific extensions make it hard to know which flow is correct for your application. Most security vulnerabilities in OAuth implementations come from choosing the wrong flow or skipping critical validation steps.
The Four Flows That Matter
OAuth 2.0 defines several grant types. In practice, four cover every modern use case.
| Flow | Use case | Client type | Involves user? |
|---|---|---|---|
| Authorization Code + PKCE | Web apps, mobile apps, SPAs | Public | Yes |
| Client Credentials | Service-to-service | Confidential | No |
| Device Authorization | Smart TVs, CLI tools | Public | Yes |
| Refresh Token | Extending session lifetime | Both | No (post-auth) |
The Implicit flow and Resource Owner Password flow are deprecated. If you're using either, migrate now.
Authorization Code with PKCE
This is the recommended flow for any application where a user logs in. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks.
// Step 1: Generate PKCE challenge
import crypto from "crypto";
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
// Step 2: Redirect user to authorization server
function getAuthorizationUrl(pkce: { challenge: string }) {
const params = new URLSearchParams({
response_type: "code",
client_id: process.env.OAUTH_CLIENT_ID!,
redirect_uri: "https://myapp.com/callback",
scope: "openid profile email",
state: crypto.randomBytes(16).toString("hex"),
code_challenge: pkce.challenge,
code_challenge_method: "S256",
});
return `https://auth.provider.com/authorize?${params}`;
}// Step 3: Exchange authorization code for tokens
async function exchangeCode(code: string, verifier: string) {
const response = await fetch("https://auth.provider.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://myapp.com/callback",
client_id: process.env.OAUTH_CLIENT_ID!,
code_verifier: verifier, // PKCE verification
}),
});
return response.json();
// { access_token: "...", refresh_token: "...", id_token: "...", expires_in: 3600 }
}The state parameter prevents CSRF attacks. The code_verifier proves that the same client that started the flow is completing it.
Client Credentials Flow
For service-to-service communication where no user is involved. The client authenticates with its own credentials.
// ✅ Service-to-service authentication
async function getServiceToken() {
const response = await fetch("https://auth.provider.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`,
).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "api:read api:write",
}),
});
return response.json();
}
// Cache the token until near expiry
let cachedToken: { token: string; expiresAt: number } | null = null;
async function getValidToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
return cachedToken.token;
}
const data = await getServiceToken();
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return cachedToken.token;
}Never expose client secrets in frontend code. Client Credentials is exclusively for server-side applications.
Common Security Mistakes
// ❌ Not validating the state parameter — CSRF vulnerability
app.get("/callback", async (req, res) => {
const { code } = req.query;
const tokens = await exchangeCode(code); // Missing state check!
});
// ✅ Always validate state
app.get("/callback", async (req, res) => {
const { code, state } = req.query;
const savedState = req.session.oauthState;
if (!state || state !== savedState) {
return res.status(403).json({ error: "Invalid state parameter" });
}
delete req.session.oauthState;
const tokens = await exchangeCode(code, req.session.pkceVerifier);
});// ❌ Not validating the ID token
const user = jwt.decode(tokens.id_token); // Decode without verify!
// ✅ Verify the ID token signature, issuer, and audience
const user = jwt.verify(tokens.id_token, publicKey, {
issuer: "https://auth.provider.com",
audience: process.env.OAUTH_CLIENT_ID,
algorithms: ["RS256"],
});Token Storage and Lifecycle
// Access token: short-lived, used for API calls
// Refresh token: long-lived, used to get new access tokens
async function refreshAccessToken(refreshToken: string) {
const response = await fetch("https://auth.provider.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: process.env.OAUTH_CLIENT_ID!,
}),
});
if (!response.ok) {
// Refresh token expired or revoked — user must re-authenticate
throw new AuthenticationError("Session expired");
}
return response.json();
}| Token | Lifetime | Storage | Refresh strategy |
|---|---|---|---|
| Access token | 15-60 minutes | Memory or HTTP-only cookie | Use refresh token |
| Refresh token | 7-30 days | HTTP-only cookie or secure server-side | Re-authenticate on expiry |
| ID token | Match access token | Memory | Not refreshed — re-fetch on access token refresh |
Key Takeaways
- Use Authorization Code + PKCE for any user-facing application — web, mobile, or SPA
- Client Credentials is for service-to-service only — never expose client secrets to browsers
- Always validate the
stateparameter — skipping it opens a CSRF vulnerability - Verify ID token signatures — decoding without verification trusts unsigned data
- Short-lived access tokens + refresh rotation limits the damage window of a stolen token
- The Implicit flow is deprecated — migrate to Authorization Code + PKCE


