@pronghorn/csrf
CSRF
CSRF is a lightweight, TypeScript-first Cross-Site Request Forgery protection middleware built as an external plugin for Pronghorn. It implements the double-submit cookie pattern, issuing a signed token cookie and verifying it against a header or body field on every mutating request.
Built as a standalone package (
@pronghorn/csrf), layers on top of@pronghorn/cookie's signing primitives, completing the security trio alongside@pronghorn/shield(headers) and@pronghorn/cookie(signed cookies).
Why CSRF
@pronghorn/shield hardens response headers and @pronghorn/cookie handles signed cookies, but neither protects against a malicious site silently submitting authenticated requests on a user's behalf. CSRF closes that gap with the double-submit cookie pattern, a widely used, stateless approach that doesn't require server-side session storage.
- Issues a signed, unpredictable token cookie automatically on first request, no manual setup per route.
- Verifies the token via a request header (
X-CSRF-Tokenby default) or a body field, your choice per client type. - Safe HTTP methods (
GET,HEAD,OPTIONS) are exempt by default, since they shouldn't mutate state anyway. - Works statelessly, no session store dependency, though it composes cleanly with
@pronghorn/sessionif you use one. - Built directly on
@pronghorn/cookie's HMAC signing and timing-safe verification, no duplicated crypto logic. - Zero additional runtime dependencies beyond
@pronghorn/cookie.
Installation
bun add @pronghorn/csrf @pronghorn/cookie
Requires Bun >=1.3.0 and pronghorn >=0.1.2 as a peer dependency (used for typing the middleware only). @pronghorn/cookie is a direct runtime dependency for its signing/parsing primitives.
Quick Start
import { createApp } from 'pronghorn'
import { csrf } from '@pronghorn/csrf'
const app = createApp()
app.use(csrf({ secret: process.env.CSRF_SECRET ?? 'dev-secret' }))
app.get('/form', context => {
return context.json({ csrfToken: context.locals.csrfToken })
})
app.post('/transfer', context => {
return context.json({ transferred: true }) // only reached if the token matched
})
await app.listen(4000)
Core Concepts
How the double-submit pattern works
On every request, CSRF ensures a signed token cookie exists (issuing one if missing) and exposes the raw token via context.locals.csrfToken. On mutating requests, it checks that the client echoed the exact same token back via a header or body field, an attacker's site can trigger a request with the victim's cookies attached, but can't read the cookie's value to forge a matching header, since cookies are same-origin but the header must be set by JavaScript the attacker doesn't control.
Embedding the token in a form
Expose the token to a server-rendered view, then include it as a hidden field or send it back as a header from client-side JS.
app.get('/checkout', context => {
const render = context.get<(view: string, data?: Record<string, unknown>) => Promise<Response>>('render')
return render('checkout', { csrfToken: context.locals.csrfToken })
})
<!-- views/checkout.fawn -->
<form method="POST" action="/checkout">
<input type="hidden" name="_csrf" value="{{ csrfToken }}" />
<button type="submit">Pay</button>
</form>
Sending the token via header (SPA/fetch clients)
const response = await fetch('/form')
const { csrfToken } = await response.json()
await fetch('/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ amount: 100 })
})
Exempting additional methods or routes
GET, HEAD, and OPTIONS are exempt by default since they shouldn't mutate state. Extend the exempt list if you have other safe, idempotent endpoints.
app.use(csrf({
secret: process.env.CSRF_SECRET!,
ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE']
}))
Body-field fallback
If a client can't set custom headers (e.g. a traditional HTML form POST), CSRF falls back to reading a body field. This requires body parsing to run first, so register @pronghorn/bodies' bodyParser() before csrf() in the middleware chain.
import { bodyParser } from '@pronghorn/bodies'
import { csrf } from '@pronghorn/csrf'
app.use(bodyParser())
app.use(csrf({ secret: process.env.CSRF_SECRET!, fieldName: '_csrf' }))
Middleware Options
| Option | Type | Default | Description |
|---|---|---|---|
secret |
string |
- | Required. HMAC signing secret for the token cookie |
cookieName |
string |
'csrf_token' |
Name of the signed token cookie |
headerName |
string |
'x-csrf-token' |
Header checked for the submitted token |
fieldName |
string |
'_csrf' |
Body field checked as a fallback if the header is absent |
ignoreMethods |
string[] |
['GET', 'HEAD', 'OPTIONS'] |
HTTP methods exempt from verification |
tokenLength |
number |
32 |
Number of random bytes used to generate each token |
API Reference
csrf(options: CsrfOptions): Middleware - global middleware factory, register via app.use(csrf(options)).
| Locals property | Type | Description |
|---|---|---|
context.locals.csrfToken |
string |
The current request's raw (unsigned) CSRF token, safe to embed in forms or return to a client |
Architecture
CSRF is implemented as a single middleware module that reuses @pronghorn/cookie's exported primitives rather than reimplementing signing logic.
| Responsibility | Implementation |
|---|---|
| Token generation | crypto.getRandomValues produces cryptographically random bytes, hex-encoded |
| Token signing/verification | @pronghorn/cookie's signValue/unsignValue (HMAC-SHA256, timing-safe comparison) |
| Token delivery to client | Signed cookie (httpOnly, sameSite: 'Strict') plus context.locals.csrfToken for embedding in responses |
| Token verification | Header lookup first, body field fallback second, compared against the unsigned cookie value |
Because the check compares the submitted token against the unsigned cookie value (not the raw signed cookie string), a forged header value can never match unless the attacker can read the legitimate cookie, which same-origin policy prevents.
Security Notes
- Always use a long, random
secret, distinct from your session or JWT secrets, and load it from an environment variable. - The cookie is set with
sameSite: 'Strict'by default, which alone blocks most cross-site submission vectors, CSRF token verification is a defense-in-depth layer on top of that. - Never expose the CSRF secret to the client, only the per-request unsigned token (via
context.locals.csrfToken) should ever reach the browser. - If you exempt additional methods via
ignoreMethods, make sure those routes are genuinely side-effect-free, exempting a mutating route defeats the protection entirely.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.