OAuth 2.0 with Node.js and TypeScript — PKCE and Authorization Code Guide 2026
Advertisement
Introduction
OAuth 2.0 Flows in 2026
OAuth 2.0 is the industry standard for delegated authorization. It lets users grant your app access to resources on another service without sharing passwords.
Which flow to use:
- Authorization Code + PKCE — web apps, mobile apps, SPAs (recommended for all public clients)
- Client Credentials — machine-to-machine / service-to-service (no user involved)
- Implicit — deprecated, do not use
- Resource Owner Password — deprecated, avoid unless migrating legacy systems
OAuth 2.1 (upcoming) mandates PKCE for all authorization code flows.
Authorization Code Flow with PKCE
import crypto from 'crypto';
import { URLSearchParams } from 'url';
// Step 1 — Generate PKCE verifier and challenge
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// Step 2 — Build authorization URL
function buildAuthorizationUrl(provider: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scopes: string[];
}, state: string, challenge: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: provider.clientId,
redirect_uri: provider.redirectUri,
scope: provider.scopes.join(' '),
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return `${provider.authorizationEndpoint}?${params}`;
}
// Usage
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
// Store verifier + state in session (server-side or signed cookie)
req.session.pkceVerifier = verifier;
req.session.oauthState = state;
const url = buildAuthorizationUrl(
{
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
clientId: process.env.GOOGLE_CLIENT_ID!,
redirectUri: 'https://yourapp.com/auth/callback',
scopes: ['openid', 'email', 'profile'],
},
state,
challenge
);
res.redirect(url);Callback — Token Exchange
// Step 3 — Exchange authorization code for tokens
async function exchangeCode(params: {
tokenEndpoint: string;
code: string;
verifier: string;
clientId: string;
clientSecret: string;
redirectUri: string;
}) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
client_secret: params.clientSecret,
code_verifier: params.verifier,
});
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return response.json() as Promise<{
access_token: string;
refresh_token?: string;
id_token?: string;
expires_in: number;
token_type: string;
}>;
}
// Callback handler
export async function oauthCallback(req: Request, res: Response) {
const { code, state, error } = req.query as Record<string, string>;
if (error) return res.status(400).json({ error });
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'State mismatch — possible CSRF attack' });
}
const tokens = await exchangeCode({
tokenEndpoint: 'https://oauth2.googleapis.com/token',
code,
verifier: req.session.pkceVerifier,
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/auth/callback',
});
// Fetch user profile
const profile = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
}).then(r => r.json());
// Upsert user in DB and issue your own session/JWT
const user = await db.users.upsert({
where: { email: profile.email },
update: { name: profile.name, avatarUrl: profile.picture },
create: { email: profile.email, name: profile.name, avatarUrl: profile.picture },
});
const jwt = generateAccessToken({ userId: user.id, email: user.email });
res.redirect(`/dashboard?token=${jwt}`);
}Client Credentials Flow (M2M)
// Service-to-service authentication
async function getClientCredentialsToken(params: {
tokenEndpoint: string;
clientId: string;
clientSecret: string;
scopes: string[];
}): Promise<string> {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: params.clientId,
client_secret: params.clientSecret,
scope: params.scopes.join(' '),
});
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = await response.json();
return data.access_token as string;
}
// Cache the token and refresh before expiry
class ServiceTokenCache {
private token: string | null = null;
private expiresAt = 0;
async getToken(): Promise<string> {
if (this.token && Date.now() < this.expiresAt - 60_000) {
return this.token;
}
const data = await fetchNewClientCredentials();
this.token = data.access_token;
this.expiresAt = Date.now() + data.expires_in * 1000;
return this.token;
}
}GitHub Social Login Example
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_USER_URL = 'https://api.github.com/user';
// Redirect to GitHub
router.get('/auth/github', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const url = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID!,
redirect_uri: 'https://yourapp.com/auth/github/callback',
scope: 'read:user user:email',
state,
});
res.redirect(`${GITHUB_AUTH_URL}?${url}`);
});
// Handle callback
router.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query as Record<string, string>;
if (state !== req.session.oauthState) {
return res.status(400).send('State mismatch');
}
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await tokenRes.json();
const profile = await fetch(GITHUB_USER_URL, {
headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json());
// Upsert and issue session
const user = await db.users.upsert({ where: { githubId: profile.id }, create: profile });
res.redirect(`/dashboard?session=${createSession(user.id)}`);
});Common Mistakes
- Using the Implicit flow — it exposes tokens in the URL fragment and is deprecated in OAuth 2.1
- Skipping state parameter validation — enables CSRF attacks on the callback endpoint
- Storing client secrets in frontend code — client secrets are for confidential server-side clients only
- Not validating the
aud(audience) claim in ID tokens — accept tokens intended for other apps - Forgetting to handle token refresh for long-running operations that outlive the access token
Best Practices
- Always use Authorization Code + PKCE for any public client (browser, mobile, desktop)
- Validate
stateon every callback to prevent CSRF - Store access tokens in memory, refresh tokens in
httpOnlycookies - Verify ID token signature using the provider's JWKS endpoint, not just trust the payload
- Use a discovery document (
.well-known/openid-configuration) to auto-configure endpoints - Cache client credentials tokens and refresh 60 seconds before expiry
Key Takeaways
- OAuth 2.0 is for authorization (granting access); OpenID Connect (OIDC) adds authentication on top
- PKCE replaces client secrets for public clients by binding the authorization request cryptographically
- The Authorization Code flow with PKCE is the only recommended flow for web and mobile apps in 2026
- State parameter validation is mandatory — it prevents CSRF attacks on the redirect callback
- Client Credentials flow is for machine-to-machine communication without user context
- Always exchange the authorization code for tokens server-side — never expose client secrets to browsers
- Validate ID tokens against the provider's JWKS endpoint, not blindly trust the payload
- OAuth 2.1 will require PKCE for all authorization code flows, making it the universal standard
Advertisement
Related reading
Clock Skew Breaking Tokens — When Servers Disagree on What Time It Is6 min readOAuth 2.0 With PKCE — Secure Authorization Code Flow for SPAs and Mobile Apps8 min readWeb Security Best Practices Every Developer Must Know5 min readAbuse of Public Endpoints — Protecting Your Free Tier From Exploitation9 min readPrisma ORM Guide 2026 — Type-Safe Database Access with PostgreSQL5 min readAPI Security Guide 2026 — OWASP Top 10, JWT, CORS, and Rate Limiting5 min read