Skip to content

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:

  1. On every request, a random token is set as a cookie (if not already present).
  2. For state-changing methods (POST, PUT, PATCH, DELETE), the middleware checks that the value in the x-csrf-token request header matches the cookie value.
  3. 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:

ts
import cookieParser from 'cookie-parser'
import { csrf } from '@forinda/kickjs'

bootstrap({
  modules,
  middlewares: [cookieParser(), csrf()],
})

CsrfOptions ​

Pass an options object to customize behavior:

ts
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.

OptionTypeDefault
cookiestring'_csrf'
headerstring'x-csrf-token'
methodsstring[]['POST', 'PUT', 'PATCH', 'DELETE']
ignorePathsstring[][]
tokenLengthnumber32
cookieOptionsobjectSee 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 ​

js
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 ​

js
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:

ts
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:

json
{ "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:

ts
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.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.