CSRF Protection
KickJS includes a double-submit cookie CSRF middleware that protects state-changing requests from cross-site request forgery attacks.
How It Works
The csrf() middleware uses the double-submit cookie pattern:
- On every request, a random token is set as a cookie (if not already present).
- For state-changing methods (POST, PUT, PATCH, DELETE), the middleware checks that the value in the
x-csrf-tokenrequest header matches the cookie value. - If the tokens do not match or the header is missing, the request is rejected with a 403 response.
Setup
The CSRF middleware requires a cookie parser to be registered before it:
import cookieParser from 'cookie-parser'
import { csrf } from '@forinda/kickjs'
bootstrap({
modules,
middlewares: [cookieParser(), csrf()],
})CsrfOptions
Pass an options object to customize behavior:
csrf({
cookie: '_csrf', // cookie name (default: '_csrf')
header: 'x-csrf-token', // header name to validate (default: 'x-csrf-token')
methods: ['POST', 'PUT', 'PATCH', 'DELETE'], // methods that require validation
ignorePaths: ['/webhooks/stripe', '/webhooks/github'],
tokenLength: 32, // bytes before hex encoding (default: 32 = 64 hex chars)
cookieOptions: {
httpOnly: false, // default: false — the page must be able to read the token
sameSite: 'strict', // default: 'strict'
secure: true, // default: true in production
path: '/', // default: '/'
},
})Why httpOnly defaults to false here
Double-submit CSRF requires the client to read the token cookie and echo it in a header. Under httpOnly: true, document.cookie returns nothing, no header is sent, and every mutating request answers 403 — the flow below could not work.
The token is not a credential: it is compared against the cookie the browser already sends, so a token an attacker cannot read is also one your own page cannot send.
Set httpOnly: true only when the client receives the token another way — rendered into the page by the server, or fetched from an endpoint of your own.
| Option | Type | Default |
|---|---|---|
cookie | string | '_csrf' |
header | string | 'x-csrf-token' |
methods | string[] | ['POST', 'PUT', 'PATCH', 'DELETE'] |
ignorePaths | string[] | [] |
tokenLength | number | 32 |
cookieOptions | object | See above |
The secure cookie flag defaults to true when NODE_ENV is 'production' and false otherwise.
Client-Side Usage
Your frontend needs to read the CSRF cookie and send it back as a header on every mutating request. The default cookie is readable by JavaScript for exactly this reason.
JavaScript / Fetch
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
return match ? match[2] : null
}
fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': getCookie('_csrf'),
},
body: JSON.stringify({ title: 'New task' }),
})Axios Interceptor
import axios from 'axios'
axios.interceptors.request.use((config) => {
const match = document.cookie.match(/(^| )_csrf=([^;]+)/)
if (match) config.headers['x-csrf-token'] = match[2]
return config
})Excluding Webhook Paths
Incoming webhooks from third-party services (Stripe, GitHub, etc.) cannot send your CSRF token. Exclude them with ignorePaths:
csrf({
ignorePaths: ['/webhooks/stripe', '/webhooks/github'],
})Path matching is exact -- /webhooks/stripe will not match /webhooks/stripe/events. If you need prefix matching, consider adding each specific path or using a separate router that does not include the CSRF middleware.
Error Response
When validation fails, the middleware returns:
{ "message": "CSRF token mismatch" }with HTTP status 403.
Exempting routes by flag
csrf() takes ignorePaths — an exact pathname — which cannot express /webhooks/:provider and keeps parsing after an apiPrefix or version change that silently voids it.
Running before route matching is not the reason it has no flag option: the connect-style rateLimit() runs there too and takes exemptWhen, reading a policy table built at boot. CSRF has no equivalent because the check itself is meaningless off a matched route — a token mismatch on a path that routes nowhere is a 404, not a 403 — so exempting one was never the useful half. The flag-aware path is the guard below.
csrfGuard() is the ctx-style counterpart. It runs inside the matched route, so it reads route flags and works on every runtime including the web entry:
import { csrfGuard, defineRouteFlag, Middleware } from '@forinda/kickjs'
const CsrfExempt = defineRouteFlag('csrf.exempt')
@Middleware(csrfGuard({ exemptWhen: 'csrf.exempt' }))
@Controller()
export class WebhooksController {
@CsrfExempt
@Post('/:provider') // exempt, params and all
receive(ctx: RequestContext) {}
@Post('/settings') // still protected
settings(ctx: RequestContext) {}
}exemptWhen takes a flag name, a list of names (matched any-of), or a predicate ({ flags, route }) => boolean.
Keep csrf() when you want one app-wide middleware that also covers requests matching no route — there is nothing to exempt there, and nothing to read.