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

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
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'
andsecure: 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
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!