惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX - 技术
D
DataBreaches.Net
阮一峰的网络日志
阮一峰的网络日志
Recent Announcements
Recent Announcements
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
K
Kaspersky official blog
F
Fortinet All Blogs
GbyAI
GbyAI
Forbes - Security
Forbes - Security
The Cloudflare Blog
博客园 - 司徒正美
博客园_首页
量子位
Schneier on Security
Schneier on Security
G
GRAHAM CLULEY
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Proofpoint News Feed
N
News | PayPal Newsroom
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
T
Tor Project blog
V
Vulnerabilities – Threatpost
Y
Y Combinator Blog
Jina AI
Jina AI
Help Net Security
Help Net Security
T
Threat Research - Cisco Blogs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
Cybersecurity and Infrastructure Security Agency CISA
Project Zero
Project Zero
N
News and Events Feed by Topic
I
Intezer
B
Blog
美团技术团队
C
CERT Recently Published Vulnerability Notes
NISL@THU
NISL@THU
L
LINUX DO - 最新话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
AWS News Blog
AWS News Blog
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
雷峰网
雷峰网
有赞技术团队
有赞技术团队

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
OAuth 2.0 PKCE: Building the Authorization Code Flow the Right Way in Node.js
How PKCE changes the authorization code flow · 2026-06-08 · via The Practical Developer

Your SPA redirects users to the OAuth provider, gets back an access token in the URL fragment, and stores it in localStorage. This is the implicit grant flow, and it has been deprecated since 2019 for a reason: the access token is exposed in the browser URL, there is no client authentication, and the token cannot be rotated without user reauthentication. But when you try to switch to the standard authorization code flow, you hit a wall. The code flow requires a client_secret to exchange the code for tokens, and you cannot safely store a secret in the browser.

PKCE (Proof Key for Code Exchange, pronounced “pixie”) solves this without a client secret. Instead of a static shared secret, the client generates a one-time cryptographic challenge, sends the hashed version during the authorization request, and presents the original verifier during the token exchange. An attacker who intercepts the authorization code cannot exchange it without the verifier, which never leaves the client.

This post builds the complete OAuth 2.0 PKCE flow in Node.js, covering both the client side (the Express/Fastify routes that initiate the flow and handle the callback) and the server side (the token endpoint that validates the challenge and issues tokens). You will end with a production-ready implementation that handles code exchange, refresh token rotation, and the edge cases that leak tokens in production.

The standard authorization code flow (RFC 6749) works like this:

  1. The client redirects the user to the authorization server with a client_id and redirect_uri.
  2. The user authenticates and authorizes the application.
  3. The authorization server redirects back to the client with an authorization code in the URL.
  4. The client sends the code plus its client_secret to the token endpoint.
  5. The authorization server verifies the secret and returns an access token (and optionally a refresh token).

Step 4 is the problem for SPAs and mobile apps. There is no safe place to store a client_secret. Anyone who inspects the bundle or decompiles the app finds it, and then they can exchange any authorization code for a token.

PKCE (RFC 7636) replaces the client_secret with a dynamically generated pair of values:

  • code_verifier: a high-entropy random string (43-128 characters from a restricted character set).
  • code_challenge: a transformation of the verifier, either the plain verifier (S256 is the hash, plain is the raw value; always use S256).

The flow becomes:

  1. The client generates code_verifier and code_challenge.
  2. The client redirects the user with the code_challenge and code_challenge_method added to the authorization request.
  3. The authorization server stores the challenge alongside the authorization code.
  4. The user authenticates and is redirected back with the code.
  5. The client sends the code plus the original code_verifier to the token endpoint.
  6. The authorization server hashes the verifier using the stored method and compares it to the stored challenge. If they match, it issues tokens.

An attacker who intercepts the authorization code at step 3 cannot exchange it because they do not have the code_verifier. The verifier was generated on the client, sent directly to the authorization server at step 5, and never transmitted anywhere else.

Building the client-side flow in Express

The client initiates the PKCE flow by generating the verifier and challenge, then redirecting the user to the authorization server.

import crypto from 'node:crypto';
import express from 'express';

const router = express.Router();

// Configuration (from the authorization server's developer portal)
const OAUTH_CONFIG = {
  authorizationEndpoint: 'https://auth.example.com/authorize',
  tokenEndpoint: 'https://auth.example.com/token',
  clientId: process.env.OAUTH_CLIENT_ID!,
  redirectUri: 'https://app.example.com/auth/callback',
  scope: 'openid profile email',
};

// Generate a cryptographically random code verifier
function generateCodeVerifier(): string {
  // 32 bytes of random data, base64url-encoded = 43 characters
  // RFC 7636 requires 43-128 characters from [A-Za-z0-9-._~]
  const buffer = crypto.randomBytes(32);
  return base64URLEncode(buffer);
}

// Base64URL-encode (no padding, no +/=, no /)
function base64URLEncode(buffer: Buffer): string {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// SHA-256 hash, base64url-encoded = S256 code challenge
async function generateCodeChallenge(verifier: string): Promise<string> {
  const hash = crypto.createHash('sha256').update(verifier).digest();
  return base64URLEncode(hash);
}

// Step 1: Initiate the PKCE flow
router.get('/auth/login', async (req, res) => {
  // Generate the verifier and challenge
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // Store the verifier in the session so we can retrieve it
  // when the callback arrives. This is critical.
  req.session.codeVerifier = codeVerifier;

  // Build the authorization URL with PKCE parameters
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: OAUTH_CONFIG.clientId,
    redirect_uri: OAUTH_CONFIG.redirectUri,
    scope: OAUTH_CONFIG.scope,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: crypto.randomUUID(), // Anti-CSRF: verified on callback
  });

  req.session.oauthState = params.get('state')!;

  // Redirect the user to the authorization server
  res.redirect(`${OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`);
});

Three things to get right in this step:

The state parameter. This is a random value included in the authorization request and verified when the callback arrives. It prevents CSRF attacks on the redirect URI. Without it, an attacker can trick your application into processing an authorization code they obtained for their own session. Generate it fresh for every request and verify it matches on callback.

Storing the verifier. The code_verifier must be retrievable when the callback arrives. A server-side session (Express session with a cookie-backed store) is the safest option. Do not store it in the browser, do not pass it through the redirect URL, do not put it in a cookie that the browser can read.

Using S256, not plain. The code_challenge_method parameter tells the authorization server how to verify the challenge on token exchange. plain sends the verifier in the authorization request itself, which defeats the purpose of PKCE. Always use S256, which sends the SHA-256 hash.

Handling the callback (the token exchange)

After the user authenticates at the authorization server, they are redirected back to your redirect_uri with an authorization code and the state parameter.

// Step 2: Handle the OAuth callback
router.get('/auth/callback', async (req, res) => {
  const { code, state, error } = req.query as Record<string, string>;

  // Handle authorization errors from the provider
  if (error) {
    console.error('Authorization error:', error);
    return res.redirect('/login?error=authorization_denied');
  }

  // Verify the state parameter to prevent CSRF
  if (state !== req.session.oauthState) {
    console.error('State mismatch: possible CSRF attack');
    req.session.destroy(() => {});
    return res.status(403).redirect('/login?error=invalid_state');
  }

  // Retrieve the stored verifier
  const codeVerifier = req.session.codeVerifier;
  if (!codeVerifier) {
    console.error('No code verifier found in session');
    req.session.destroy(() => {});
    return res.status(400).redirect('/login?error=missing_verifier');
  }

  try {
    // Exchange the authorization code for tokens
    const tokens = await exchangeCodeForTokens(code, codeVerifier);

    // Store tokens securely (see below)
    await storeTokens(req, tokens);

    // Clean up session artifacts
    delete req.session.codeVerifier;
    delete req.session.oauthState;

    // Redirect to the application
    res.redirect('/dashboard');
  } catch (err) {
    console.error('Token exchange failed:', err);
    res.status(500).redirect('/login?error=token_exchange_failed');
  }
});

// Exchange the authorization code for access and refresh tokens
async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string
): Promise<TokenResponse> {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: OAUTH_CONFIG.redirectUri,
    client_id: OAUTH_CONFIG.clientId,
    code_verifier: codeVerifier,
  });

  const response = await fetch(OAUTH_CONFIG.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: body.toString(),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(
      `Token exchange failed: ${response.status} ${errorBody}`
    );
  }

  return response.json() as Promise<TokenResponse>;
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  refresh_token?: string;
  id_token?: string;
  scope?: string;
}

The token exchange is a server-to-server call. The code_verifier is sent directly from your server to the authorization server over TLS. An attacker who intercepted the authorization code from the redirect URL cannot exchange it because they do not have the verifier.

After this step, you have an access token (short-lived, used for API calls) and optionally a refresh token (long-lived, used to get new access tokens when they expire).

Storing tokens securely on the server

Where you store the tokens depends on what kind of client you are building.

For a server-side rendered application (Express with sessions), store the tokens in the session store. Use a Redis-backed session store, not the default in-memory store, and encrypt the token payload before writing it to Redis. The access token is in-memory for the duration of the request, and the session ID is the only thing in the browser cookie.

import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL! });
await redisClient.connect();

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET!, // 32+ random bytes
    name: 'session_id',                  // not 'connect.sid'
    cookie: {
      httpOnly: true,    // No JavaScript access
      secure: true,      // HTTPS only
      sameSite: 'lax',   // CSRF protection
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    },
    resave: false,
    saveUninitialized: false,
  })
);

For an SPA (the browser handles the redirect), you have a harder problem. The token arrives on the callback URL and must be passed to the SPA. The safest approach is to have the server receive the callback, exchange the code for tokens, set an httpOnly session cookie, and return the page. The SPA never sees the raw token.

Never store access tokens in localStorage when you can avoid it. An httpOnly session cookie is immune to XSS exfiltration. If you must use localStorage (for example, because you are building a mobile app or a CLI tool), the PKCE verifier protects the initial code exchange, but the stored token is still vulnerable to the runtime environment.

Refreshing tokens with PKCE

Access tokens expire (typically in 15-60 minutes). The authorization code flow also returns a refresh_token that can be used to get new access tokens without user interaction. The refresh token grant does not use PKCE (the refresh token itself is the credential), but it should be protected by refresh token rotation.

async function refreshAccessToken(
  refreshToken: string,
  clientId: string
): Promise<TokenResponse> {
  const body = new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
    client_id: clientId,
  });

  const response = await fetch(OAUTH_CONFIG.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: body.toString(),
  });

  if (!response.ok) {
    throw new Error(`Token refresh failed: ${response.status}`);
  }

  return response.json() as Promise<TokenResponse>;
}

The token response from a refresh grant includes a new access_token and, critically, a new refresh_token. This is refresh token rotation: every time you use a refresh token, the authorization server issues a new one and invalidates the old one. If a refresh token is ever used twice (because an attacker stole it and both you and the attacker try to use it), the authorization server detects the reuse and invalidates the entire token family.

Implement reuse detection on your end by storing a counter or a last-used timestamp alongside the refresh token in your database:

async function rotateRefreshToken(
  userId: string,
  currentRefreshToken: string,
  newRefreshToken: string
): Promise<void> {
  // In a transaction:
  // 1. Read the stored refresh token hash for this user
  // 2. If the stored hash matches the current token hash, update it
  // 3. If the stored hash does not match, the token was compromised
  const stored = await db.refreshTokens.findUnique({ where: { userId } });

  if (!stored) {
    // No stored token, this might be the first rotation
    await db.refreshTokens.create({
      data: {
        userId,
        tokenHash: hashToken(newRefreshToken),
        family: crypto.randomUUID(),
        createdAt: new Date(),
        rotatedAt: new Date(),
      },
    });
    return;
  }

  if (stored.tokenHash !== hashToken(currentRefreshToken)) {
    // Token reuse detected. Someone else used this refresh token.
    // Invalidate the entire token family.
    console.error(`Refresh token reuse detected for user ${userId}`);
    await db.refreshTokens.deleteMany({ where: { family: stored.family } });
    // Force the user to re-authenticate
    throw new Error('refresh_token_reuse_detected');
  }

  await db.refreshTokens.update({
    where: { userId },
    data: {
      tokenHash: hashToken(newRefreshToken),
      rotatedAt: new Date(),
    },
  });
}

function hashToken(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}

What can go wrong in production

Teams that implement PKCE for the first time make the same mistakes. Here are the three that cause the most damage in production.

Mistake 1: Storing the verifier in the redirect URL

Some tutorials suggest passing the code_verifier through the redirect URL as a query parameter so the server can retrieve it. This defeats PKCE. If an attacker can read the redirect URL (browser history, server logs, Referer header), they have the verifier and can exchange the intercepted authorization code for tokens.

// WRONG: verifier in the redirect URL
const callbackUrl = `https://app.example.com/auth/callback?verifier=${codeVerifier}`;
// Now any attacker who can read URLs has the verifier.

The verifier must be stored server-side in the session. The session cookie is the only client-side artifact.

Mistake 2: Forgetting the state parameter

The state parameter is not optional. Without it, an attacker can craft a URL like this:

https://app.example.com/auth/callback?code=ATTACKER_CODE&state=...

If your application does not verify that state matches what it generated, the attacker can force your application to exchange their authorization code for tokens, linking their OAuth account to your user’s session. This is a classic CSRF attack on the OAuth callback.

The fix is a single line in the callback handler:

if (state !== req.session.oauthState) {
  // Reject the callback
}

Mistake 3: Not validating the token response signature

If the authorization server returns a JWT access token or an ID token, validate its signature on every callback. A malformed token response or an MITM attacker could inject a forged token, and without signature validation, your application will accept it.

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
});

async function verifyToken(token: string): Promise<jwt.JwtPayload> {
  const getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
    client.getSigningKey(header.kid, (err, key) => {
      if (err) return callback(err);
      callback(null, key!.getPublicKey());
    });
  };

  return new Promise((resolve, reject) => {
    jwt.verify(token, getKey, {
      algorithms: ['RS256'],
      issuer: 'https://auth.example.com/',
      audience: OAUTH_CONFIG.clientId,
    }, (err, payload) => {
      if (err) reject(err);
      else resolve(payload as jwt.JwtPayload);
    });
  });
}

Use RS256 (asymmetric signing) instead of HS256 (symmetric shared secret). With HS256, any client that knows the secret can forge tokens. With RS256, the authorization server signs with a private key and clients verify with a public key, which is safe to distribute via the JWKS endpoint.

Building your own PKCE authorization server in Node.js

Sometimes you are not the client. You are the authorization server, and your partners or your own SPAs need to authenticate through your service. Here is the minimal PKCE-compliant token endpoint in Express:

import crypto from 'node:crypto';

interface AuthorizationCode {
  clientId: string;
  redirectUri: string;
  codeChallenge: string;
  codeChallengeMethod: string;
  userId: string;
  expiresAt: Date;
}

// In-memory store (use Redis in production)
const authCodes = new Map<string, AuthorizationCode>();
const refreshTokens = new Map<string, { userId: string; family: string }>();

// Authorization endpoint - validates PKCE params and issues code
router.post('/authorize', (req, res) => {
  // Validate the user is authenticated (session, redirect to login, etc.)
  const { client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.body;

  if (code_challenge_method !== 'S256') {
    return res.status(400).json({ error: 'invalid_request',
      error_description: 'Only S256 code_challenge_method is supported' });
  }

  if (!code_challenge || code_challenge.length < 43) {
    return res.status(400).json({ error: 'invalid_request',
      error_description: 'Invalid code_challenge' });
  }

  // Generate the authorization code
  const code = crypto.randomUUID();
  authCodes.set(code, {
    clientId: client_id,
    redirectUri: redirect_uri,
    codeChallenge: code_challenge,
    codeChallengeMethod: code_challenge_method,
    userId: req.session.userId,
    expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
  });

  // Redirect back to the client
  const redirectUrl = new URL(redirect_uri);
  redirectUrl.searchParams.set('code', code);
  redirectUrl.searchParams.set('state', state);
  res.redirect(redirectUrl.toString());
});

// Token endpoint - validates the code_verifier against the stored challenge
router.post('/token', async (req, res) => {
  const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body;

  if (grant_type === 'authorization_code') {
    const stored = authCodes.get(code);
    if (!stored) {
      return res.status(400).json({ error: 'invalid_grant',
        error_description: 'Invalid authorization code' });
    }

    if (stored.expiresAt < new Date()) {
      authCodes.delete(code);
      return res.status(400).json({ error: 'invalid_grant',
        error_description: 'Authorization code expired' });
    }

    if (stored.redirectUri !== redirect_uri) {
      return res.status(400).json({ error: 'invalid_grant',
        error_description: 'redirect_uri mismatch' });
    }

    // Validate the code_verifier
    const expectedChallenge = base64URLEncode(
      crypto.createHash('sha256').update(code_verifier).digest()
    );

    if (expectedChallenge !== stored.codeChallenge) {
      return res.status(400).json({ error: 'invalid_grant',
        error_description: 'code_verifier does not match code_challenge' });
    }

    // Code is valid and verifier matches. Issue tokens.
    authCodes.delete(code);

    const accessToken = await signAccessToken(stored.userId, stored.clientId);
    const refreshToken = crypto.randomUUID();

    refreshTokens.set(refreshToken, {
      userId: stored.userId,
      family: crypto.randomUUID(),
    });

    return res.json({
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 3600,
      refresh_token: refreshToken,
    });
  }

  if (grant_type === 'refresh_token') {
    // Validate and rotate the refresh token
    const stored = refreshTokens.get(req.body.refresh_token);
    if (!stored) {
      return res.status(400).json({ error: 'invalid_grant' });
    }

    // Rotate: issue new refresh token, invalidate old one
    refreshTokens.delete(req.body.refresh_token);
    const newRefreshToken = crypto.randomUUID();
    refreshTokens.set(newRefreshToken, stored);

    const accessToken = await signAccessToken(stored.userId, client_id);

    return res.json({
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 3600,
      refresh_token: newRefreshToken,
    });
  }

  return res.status(400).json({ error: 'unsupported_grant_type' });
});

async function signAccessToken(userId: string, clientId: string): Promise<string> {
  // Use RS256 with a key loaded from environment/config
  // In production, load from a secure key management service
  return jwt.sign(
    {
      sub: userId,
      client_id: clientId,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 3600,
    },
    privateKey,
    { algorithm: 'RS256' }
  );
}

This is a minimal implementation. A production authorization server needs:

  • Rate limiting on the token endpoint (brute force attacks on authorization codes).
  • Refresh token reuse detection (store a counter per token family).
  • Client authentication (at minimum validate client_id against a registered list).
  • Proper error responses per RFC 6749 (consistent error codes so clients can handle them programmatically).

The practical takeaway

If you are building an application that authenticates users through an OAuth provider, you should never implement the implicit grant flow. It has been deprecated for seven years and every browser vendor has been actively removing support for the fragment-based redirect patterns it relies on.

PKCE transforms the authorization code flow from a flow that requires a server-side confidential client into one that any client (SPA, mobile app, CLI tool, IoT device) can use securely. The difference is a dynamically generated secret that exists for exactly one authorization request and is never transmitted anywhere that an attacker can intercept it.

The three rules for getting PKCE right:

  1. Store the verifier server-side. The session cookie is the only client-side artifact. Never pass the verifier through a URL.
  2. Always include and verify the state parameter. It is the CSRF defense for the callback endpoint.
  3. Always validate the token signature. If the authorization server returns JWTs, verify them with the provider’s JWKS endpoint before trusting their contents.

The code in this post is the complete loop: client initiates the flow, server issues tokens with challenge validation, and both sides handle refresh with rotation. Copy the patterns, adapt them to your provider’s endpoints, and you have a secure auth flow that passes any security review.


A note from Yojji

Authentication architecture is one of those things that looks simple in a tutorial and gets complex fast in production: multiple providers, token versioning, refresh rotation across server restarts, and the session store scaling to millions of users. Yojji’s teams have built auth systems across fintech, healthcare, and SaaS products where a single auth flaw means a compliance incident.

Yojji is an international custom software development company with offices in Europe, the US, and the UK. They specialize in full-cycle software delivery across the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms, and microservices architectures. Their senior engineers handle the security-critical pieces like OAuth infrastructure so your team can focus on the product logic that differentiates you.