Building Your Own HMAC-Signed Double-Submit CSRF

For years, Express apps have relied on the csurf middleware to defend against Cross-Site Request Forgery. But with its deprecation, teams need a fresh approach that: Scales in microservice architectures Requires zero server-side state Is easy to unit-test and audit Can work with separate frontend apps Enter the HMAC-signed double-submit CSRF pattern: a stateless, purely functional design that integrates neatly into any framework. CSRF 101 Recap What is CSRF? An attacker lures an authenticated user’s browser into submitting a forged request (e.g. password reset, funds transfer) to your app. If you depend solely on cookies for auth, the browser will dutifully include session cookies—unless you add a token check. Two common patterns: 1.Synchronizer-Token: Server generates a random token, stores it in the user’s session. Embeds token in forms/pages. On submission, server looks up the session’s token and compares. 2.Double-Submit Cookie: Server issues a token in a cookie (readable by JS). Client reads that cookie and echoes it back in a header or hidden field. Server verifies that the cookie’s token matches the submitted token. The classic synchronizer approach is stateful—requiring a shared session store—whereas double-submit can be entirely stateless. Design Goals Before diving into code, let’s lay out our non-functional requirements: Stateless: no session DB, no Redis, no per-user token storage. Pure functions: cryptographic and parsing logic that’s side-effect-free and unit-testable. Composable middleware: easy to mount per-route or across routers. Framework-agnostic: adapt to Koa, Fastify, GraphQL, you name it. Secure: HMAC signing to prevent attackers from forging tokens. Core Primitives All the heavy lifting lives in three pure functions: import { randomBytes, createHmac, timingSafeEqual } from 'crypto' /** generateRawToken :: Number → Buffer */ export const generateRawToken = (size: number) => randomBytes(size) /** signToken :: (Buffer, String) → String */ export const signToken = (token: Buffer, secret: string) => createHmac('sha256', secret).update(token).digest('hex') /** bundleToken :: (Buffer, String) → String */ export const bundleToken = (token: Buffer, secret: string) => { const sig = signToken(token, secret) return `${token.toString('hex')}.${sig}` } /** unbundleToken :: (String, String) → { valid: Boolean; raw?: Buffer } */ export const unbundleToken = (bundled: string, secret: string) => { const [hex, sig] = bundled.split('.') if (!hex || !sig) return { valid: false } const token = Buffer.from(hex, 'hex') const expected = signToken(token, secret) const valid = timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) return { valid, raw: valid ? token : undefined } } generateRawToken: cryptographically secure random bytes. signToken: creates an HMAC-SHA256 signature. bundleToken: encodes both the token and its signature in one string. unbundleToken: splits, verifies via HMAC and constant-time compare. Because these are pure functions, you can write straightforward Jest or Vitest tests to cover every branch. Middleware Factory (createDoubleSubmitCsrf.ts) We wrap our primitives in a factory that yields two Express middlewares: attachToken and verifyToken. import { Request, Response, NextFunction } from 'express' import { generateRawToken, bundleToken, unbundleToken } from './csrf' export interface CsrfOptions { cookieName: string headerName: string secret: string tokenSize?: number } export const createDoubleSubmitCsrf = (opts: CsrfOptions) => { const size = opts.tokenSize ?? 16 function attachToken(req: Request, res: Response, next: NextFunction) { if (['GET','HEAD','OPTIONS'].includes(req.method)) { const raw = generateRawToken(size) const bundled = bundleToken(raw, opts.secret) // Non-HttpOnly so frontend JS can read it res.cookie(opts.cookieName, bundled, { httpOnly: false, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }) res.locals.csrfToken = bundled } next() } function verifyToken(req: Request, res: Response, next: NextFunction) { if (['POST','PUT','PATCH','DELETE'].includes(req.method)) { const cookieVal = req.cookies[opts.cookieName] as string const headerVal = req.get(opts.headerName) || req.body[opts.headerName] || req.body._csrf const { valid: ok1 } = cookieVal ? unbundleToken(cookieVal, opts.secret) : { valid: false } const { valid: ok2 } = headerVal ? unbundleToken(headerVal, opts.secret) : { valid: false } if (!ok1 || !ok2 || cookieVal !== headerVal) { return res.status(403).send('Invalid CSRF token') } } next() } return { attachToken, verifyToken } } attachToken runs on safe methods, issuing a new bundled token cookie and exposing it via res.locals. verifyToken ru

May 4, 2025 - 22:47
 0
Building Your Own HMAC-Signed Double-Submit CSRF

For years, Express apps have relied on the csurf middleware to defend against Cross-Site Request Forgery. But with its deprecation, teams need a fresh approach that:

  • Scales in microservice architectures

  • Requires zero server-side state

  • Is easy to unit-test and audit

  • Can work with separate frontend apps

Enter the HMAC-signed double-submit CSRF pattern: a stateless, purely functional design that integrates neatly into any framework.

sexy look

CSRF 101 Recap

What is CSRF?

An attacker lures an authenticated user’s browser into submitting a forged request (e.g. password reset, funds transfer) to your app.

If you depend solely on cookies for auth, the browser will dutifully include session cookies—unless you add a token check.

Two common patterns:

1.Synchronizer-Token:

  • Server generates a random token, stores it in the user’s session.

  • Embeds token in forms/pages.

  • On submission, server looks up the session’s token and compares.

2.Double-Submit Cookie:

  • Server issues a token in a cookie (readable by JS).

  • Client reads that cookie and echoes it back in a header or hidden field.

  • Server verifies that the cookie’s token matches the submitted token.

The classic synchronizer approach is stateful—requiring a shared session store—whereas double-submit can be entirely stateless.

Design Goals

Before diving into code, let’s lay out our non-functional requirements:

  • Stateless: no session DB, no Redis, no per-user token storage.

  • Pure functions: cryptographic and parsing logic that’s side-effect-free and unit-testable.

  • Composable middleware: easy to mount per-route or across routers.

  • Framework-agnostic: adapt to Koa, Fastify, GraphQL, you name it.

  • Secure: HMAC signing to prevent attackers from forging tokens.

Core Primitives

All the heavy lifting lives in three pure functions:

import { randomBytes, createHmac, timingSafeEqual } from 'crypto'

/** generateRawToken :: Number → Buffer */
export const generateRawToken = (size: number) =>
  randomBytes(size)

/** signToken :: (Buffer, String) → String */
export const signToken = (token: Buffer, secret: string) =>
  createHmac('sha256', secret).update(token).digest('hex')

/** bundleToken :: (Buffer, String) → String */
export const bundleToken = (token: Buffer, secret: string) => {
  const sig = signToken(token, secret)
  return `${token.toString('hex')}.${sig}`
}

/** unbundleToken :: (String, String) → { valid: Boolean; raw?: Buffer } */
export const unbundleToken = (bundled: string, secret: string) => {
  const [hex, sig] = bundled.split('.')
  if (!hex || !sig) return { valid: false }
  const token = Buffer.from(hex, 'hex')
  const expected = signToken(token, secret)
  const valid  = timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  return { valid, raw: valid ? token : undefined }
}

generateRawToken: cryptographically secure random bytes.

signToken: creates an HMAC-SHA256 signature.

bundleToken: encodes both the token and its signature in one string.

unbundleToken: splits, verifies via HMAC and constant-time compare.

Because these are pure functions, you can write straightforward Jest or Vitest tests to cover every branch.

Middleware Factory (createDoubleSubmitCsrf.ts)

We wrap our primitives in a factory that yields two Express middlewares: attachToken and verifyToken.

import { Request, Response, NextFunction } from 'express'
import { generateRawToken, bundleToken, unbundleToken } from './csrf'

export interface CsrfOptions {
  cookieName: string
  headerName: string
  secret: string
  tokenSize?: number
}

export const createDoubleSubmitCsrf = (opts: CsrfOptions) => {
  const size = opts.tokenSize ?? 16

  function attachToken(req: Request, res: Response, next: NextFunction) {
    if (['GET','HEAD','OPTIONS'].includes(req.method)) {
      const raw     = generateRawToken(size)
      const bundled = bundleToken(raw, opts.secret)

      // Non-HttpOnly so frontend JS can read it
      res.cookie(opts.cookieName, bundled, {
        httpOnly: false,
        sameSite: 'lax',
        secure: process.env.NODE_ENV === 'production'
      })
      res.locals.csrfToken = bundled
    }
    next()
  }

  function verifyToken(req: Request, res: Response, next: NextFunction) {
    if (['POST','PUT','PATCH','DELETE'].includes(req.method)) {
      const cookieVal = req.cookies[opts.cookieName] as string
      const headerVal =
        req.get(opts.headerName) ||
        req.body[opts.headerName] ||
        req.body._csrf

      const { valid: ok1 } = cookieVal ? unbundleToken(cookieVal, opts.secret) : { valid: false }
      const { valid: ok2 } = headerVal ? unbundleToken(headerVal, opts.secret) : { valid: false }

      if (!ok1 || !ok2 || cookieVal !== headerVal) {
        return res.status(403).send('Invalid CSRF token')
      }
    }
    next()
  }

  return { attachToken, verifyToken }
}

attachToken runs on safe methods, issuing a new bundled token cookie and exposing it via res.locals.

verifyToken runs on mutating methods, checks cookie vs. header/body match and HMAC validity.

Wiring Up Express

import express from 'express'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import { createDoubleSubmitCsrf } from './createDoubleSubmitCsrf'

const app = express()
app.use(express.json(), express.urlencoded({ extended: true }), cookieParser())

// Allow your front-end origin to receive and send cookies
app.use(cors({
  origin: 'https://your.frontend.domain',
  credentials: true
}))

const { attachToken, verifyToken } = createDoubleSubmitCsrf({
  cookieName: 'XSRF-TOKEN',
  headerName: 'x-xsrf-token',
  secret: process.env.CSRF_SECRET!
})

app.use(attachToken)
app.use(verifyToken)

app.get('/reset-password', (req, res) =>
  res.render('reset-password', { csrfToken: res.locals.csrfToken })
)

app.post('/reset-password', (req, res) => {
  // safe: CSRF already verified
  res.send('Password reset!')
})

app.listen(3000)

Ensure you set credentials: 'include' on your frontend fetches so cookies travel across origins.

Separate-Client Usage

In your React/Vue/Angular front-end:

// Read the cookie set by the server:
function getCsrfToken() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1]
}

fetch('https://api.yoursite.com/reset-password', {
  method: 'POST',
  credentials: 'include',             // send the XSRF-TOKEN cookie
  headers: {
    'Content-Type': 'application/json',
    'x-xsrf-token': getCsrfToken()    // echo back in header
  },
  body: JSON.stringify({ /* payload */ })
})

If you truly need cross-site cookie delivery (e.g. embedded iframes), switch sameSite: 'none' and secure: true.

Why It’s Better Than csurf

Criterion csurf HMAC Double-Submit (Ours)
State Stateful (session store needed) Stateless (no DB/cache)
Testability Harder to test middleware + sessions Pure functions + small middlewares
Microservices-ready Requires shared session backend Easy to drop into any service
Framework lock-in Express-only Copy-paste core logic anywhere

You’re no longer bound to a deprecated package. By embracing a functional, stateless, and HMAC-signed double-submit pattern, you:

  • Eliminate session storage concerns

  • Gain in-depth test coverage of pure crypto logic

  • Remain flexible across frameworks and architectures

happy

Hey! I recently created a tool called express-admin-honeypot.

Feel free to check it out, and if you like it, consider leaving a generous star on my GitHub!