OAuth 2.0 with Node.js and TypeScript — PKCE and Authorization Code Guide 2026

Sanjeev SharmaSanjeev Sharma
6 min read

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 state on every callback to prevent CSRF
  • Store access tokens in memory, refresh tokens in httpOnly cookies
  • 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading