coraza/node v0.1.0-preview.3
Docs/Coraza for Node.js

Coraza for Node.js

OWASP Coraza is a full-featured web application firewall. coraza-node ships it as a plain npm package — the engine compiles to WebAssembly via TinyGo, so there's no sidecar, no native build step. Drop an adapter into your server and you have CRS protection in front of every request.

npm@coraza/core@0.1.0-preview.3 runtimeNode ≥ 22 engineCoraza v3 · WASM rulesetCRS 4.25 bundled licenseApache-2.0
01Secure by default

Block mode, fail-closed on WAF errors, static asset bypass already wired up. Flip to 'detect' for dry-run rollout.

02Real middleware, not a proxy

Runs inside your process. Access req/res, custom onBlock, skip routes, attach metadata — all in-band.

03Pool-first by design

createWAFPool is the default for production — one WAF per CPU core, round-robin dispatch, ~4.5× throughput. createWAF is reserved for single-threaded contexts (tests, lambdas, CLIs).

How it works

Every adapter wraps the same engine from @coraza/core. At boot, the WASM module is instantiated once (or N times, in a pool) and kept warm. On each request, the adapter opens a transaction, sends connection + URI + headers + body to the engine in a single fused call, and gets back a pass/block verdict.

Note
Request phases 1 and 2 run atomically in one WASM call. This guarantees CRS's anomaly-score rule 949110 fires on body-less GET requests, where most SQLi and XSS payloads live.

Install

Pick the adapter that matches your framework. Each one depends on @coraza/core and @coraza/coreruleset.

~/my-app
# pnpm
pnpm add @coraza/express@preview @coraza/core@preview @coraza/coreruleset@preview

# npm
npm install @coraza/express@preview @coraza/core@preview @coraza/coreruleset@preview

# yarn
yarn add @coraza/express@preview @coraza/core@preview @coraza/coreruleset@preview

# bun
bun add @coraza/express@preview @coraza/core@preview @coraza/coreruleset@preview

# or pin: @0.1.0-preview.3 (core/express/fastify/next/nestjs); @0.1.0-preview.2 (coreruleset)

Quick start

Everything below uses recommended() from @coraza/coreruleset, which activates CRS with Node-appropriate defaults (PHP/Java/.NET rules excluded).

import os from 'node:os'
import express from 'express'
import { createWAFPool } from '@coraza/core'
import { coraza } from '@coraza/express'
import { recommended } from '@coraza/coreruleset'

const app = express()
app.use(express.json())

const waf = await createWAFPool({
  rules: recommended(),
  mode: 'block',
  size: os.availableParallelism(),
})
app.use(coraza({ waf }))

app.get('/', (_req, res) => res.json({ ok: true }))
app.listen(3000)
Single-WAF alternative
createWAF returns a synchronous, single-threaded WAF. Use it only for tests, lambdas, CLIs, or anywhere you don't want worker threads. Long-running HTTP servers should always pool.
Reference

@coraza/core

The framework-agnostic engine. Every adapter calls into this.

createWAF(config)

async factory

Loads the Coraza WASM, compiles the provided SecLang directives, and returns a long-lived WAF instance. Call once at boot and reuse across requests.

signature
function createWAF(config: WAFConfig): Promise<WAF>

interface WAFConfig {
  /** SecLang directives. Usually recommended() from @coraza/coreruleset. */
  rules: string
  /** 'detect' logs matches; 'block' denies on interruption. Default 'detect'. */
  mode?: 'detect' | 'block'
  /** Custom logger. Defaults to console. */
  logger?: Logger
  /** Override the embedded WASM binary (tests / custom builds only —
   *  the default loader is bundler-resilient and handles Next 15 / Turbopack
   *  rewriting `import.meta.url`. No override required in normal apps. */
  wasmSource?: ArrayBufferLike | Uint8Array | URL | string
}

Example

import { createWAF } from '@coraza/core'
import { recommended } from '@coraza/coreruleset'

const waf = await createWAF({
  rules: recommended({
    paranoia: 1,
    excludeCategories: ['scanner-detection', 'dos-protection'],
  }),
  mode: 'block',
})

createWAFPool(config)

async factory

Same config as createWAF, plus size — spawns N worker_threads each holding a WAF. Round-robins least-busy. Same adapter API; pass the pool where you'd pass a WAF.

signature
function createWAFPool(config: WAFPoolOptions): Promise<WAFPool>

interface WAFPoolOptions extends WAFConfig {
  /** Worker count. Default os.availableParallelism(). */
  size?: number
}

Example

import os from 'node:os'
import { createWAFPool } from '@coraza/core'

const waf = await createWAFPool({
  rules: recommended(),
  mode: 'block',
  size: os.availableParallelism(),
})

Transaction

type

Per-request handle opened via waf.newTransaction(). Adapters use this internally; reach for it only when writing a custom adapter. Request phases 1 + 2 run atomically via processRequestBundle.

signature
interface Transaction {
  /** Run phases 1+2 atomically in one WASM call. Preferred entrypoint. */
  processRequestBundle(req: RequestInfo, body?: Uint8Array | string): boolean

  /** Response phase (only when adapter.inspectResponse=true). */
  processResponse(res: ResponseInfo): boolean
  processResponseBody(body?: Uint8Array | string): boolean

  interruption(): Interruption | null
  matchedRules(): MatchedRule[]

  /** Short-circuit predicates — check these before ingesting data. */
  isRuleEngineOff(): boolean
  isRequestBodyAccessible(): boolean
  isResponseBodyProcessable(): boolean

  processLogging(): void
  close(): void
}

interface Interruption {
  ruleId: number
  action: string    // 'deny' | 'drop' | ...
  status: number
  data: string
}
Reference

@coraza/coreruleset

Presets and tuning knobs for OWASP CRS. Emit SecLang; pass to createWAF.

recommended(options?)
CRS in blocking mode, paranoia 1. Excludes php/java/dotnet rules (keeps nodejs + generic). Good default.
balanced(options?)
Alias for recommended. Explicit name for the paranoia-1 baseline.
strict(options?)
Paranoia 2 + tighter inbound threshold (3). More catches, more false positives.
permissive(options?)
Paranoia 1, looser thresholds, non-blocking on anomaly. Staged rollout.
excludeCategory(cat)
Emit a single SecRuleRemoveById for one of 20 categories (scanner-detection, dos-protection, xss, sqli, etc.).
excludeByTag(tags)
Low-level escape hatch: emit raw SecRuleRemoveByTag lines.
adapter

Express · @coraza/express

Drop-in middleware. Works with Express 4 and 5.

coraza(options)

middleware

Returns a RequestHandler. Takes a pre-built WAF (or WAFPool) so construction happens once at boot and the middleware stays sync-to-mount.

signature
function coraza(options: CorazaExpressOptions): RequestHandler

interface CorazaExpressOptions {
  /** Built via createWAF / createWAFPool. */
  waf: WAF | WAFPool
  /** Custom block response. Default: 403 text/plain with rule id. */
  onBlock?: (i: Interruption, req: Request, res: Response) => void
  /** Run response-phase rules too. Default false. */
  inspectResponse?: boolean
  /** Unified bypass spec — extensions, routes, methods, body cutoff, header
   *  equality, custom predicate. See "Skipping the WAF" below. */
  ignore?: IgnoreSpec | false
  /** WAF error behavior. Default 'block' — fail-closed. */
  onWAFError?: 'allow' | 'block'
}

Example — custom block handler + pool

import { createWAFPool } from '@coraza/core'
import { coraza } from '@coraza/express'

const waf = await createWAFPool({
  rules: recommended(),
  mode: 'block',
  size: 8,
})

app.use(coraza({
  waf,
  ignore: { routes: ['/health*', '/metrics*'] },
  onBlock(it, req, res) {
    metrics.inc('waf.blocked', { ruleId: it.ruleId })
    res.status(it.status || 403).json({
      error: 'forbidden',
      ruleId: it.ruleId,
      traceId: req.headers['x-trace-id'],
    })
  },
}))

Skipping the WAF — ignore

Pass ignore: to declare which requests bypass Coraza. Every field is optional; combine freely. Identical shape across every adapter.

FieldTypeExample
extensionsstring[]['css','js','min.js']
routes(string | RegExp)[]['/static/*', /^\/internal\//]
methodsstring[]['OPTIONS','HEAD']
bodyLargerThannumber (bytes)10_000_000
headerEqualsRecord<string, string | string[]>{ 'x-internal': 'true' }
match(ctx) => boolean | 'skip-body'custom predicate, sync only
skipDefaultsbooleantrue drops the built-in extension list

Verdicts: false (inspect), true (skip everything), 'skip-body' (inspect URL + headers, skip the body phase). When both declarative rules and match produce a verdict, most-restrictive wins: false > 'skip-body' > true.

The legacy skip: option is soft-deprecated for one preview, mapped to ignore: at construction with a one-shot warning per process, and removed at stable 0.1.

adapter

Fastify · @coraza/fastify

First-class Fastify plugin. Uses preHandler so req.body is in hand for phases 1+2 atomic.

fastify.register(coraza, options)

plugin

Encapsulatable — register inside a scope to protect a subset of routes. Forwards block logs through request.log (Pino by default).

signature
const coraza: FastifyPluginAsync<CorazaFastifyOptions>

interface CorazaFastifyOptions {
  waf: WAF | WAFPool
  onBlock?: (i: Interruption, req: FastifyRequest, reply: FastifyReply) => void | Promise<void>
  inspectResponse?: boolean
  ignore?: IgnoreSpec | false
  onWAFError?: 'allow' | 'block'
}

Example — scoped to /api

import Fastify from 'fastify'
import { createWAF } from '@coraza/core'
import { coraza } from '@coraza/fastify'

const app = Fastify({ logger: true })
const waf = await createWAF({ rules: recommended(), mode: 'block' })

app.register(async (api) => {
  await api.register(coraza, {
    waf,
    onBlock(it, req, reply) {
      req.log.warn({ it }, 'waf_block')
      reply.code(it.status || 403).send({ error: 'forbidden' })
    },
  })
}, { prefix: '/api' })
adapter

Next.js · @coraza/next

Next 15 (middleware.ts + runtime: 'nodejs') or Next 16 (proxy.ts). Edge runtime lacks WASI; Next 14 is intentionally unsupported. App Router and Pages Router.

Limitation — no response-body inspection
Next middleware runs on the request boundary. Route Handlers own the Response, and Next's runtime doesn't hand the response body back to middleware — that's a Next architectural choice, not a WAF limitation. Consequence: the CRS RESPONSE-95*-DATA-LEAKAGES-* families (SQL / Java / PHP / IIS error leaks, webshell output) can't fire on Next. Inbound protection — SQLi, XSS, RCE, LFI, RFI, scanner-detection, protocol-attack, anomaly scoring — works identically to every other adapter.

If you need response-body inspection, put Express or Fastify in front and run coraza-node there. Documented in ftw-overrides-next.yaml as the [next-only] go-ftw skips.

coraza(options)

middleware

Returns a Next middleware export. Accepts either a built WAF / WAFPool or a promise of one — useful for deferring WASM compile in CJS setups without top-level await.

signature
function coraza(options: CorazaNextOptions): NextMiddleware

interface CorazaNextOptions {
  waf: WAF | WAFPool | Promise<WAF | WAFPool>
  onBlock?: (i: Interruption, req: NextRequest) => Response
  ignore?: IgnoreSpec | false
  onWAFError?: 'allow' | 'block'
}

Example — Next 16 proxy.ts

import { createWAF } from '@coraza/core'
import { coraza } from '@coraza/next'
import { recommended } from '@coraza/coreruleset'
import { NextResponse } from 'next/server'

const waf = createWAF({ rules: recommended(), mode: 'block' })

export const proxy = coraza({
  waf,
  onBlock(it) {
    return NextResponse.json(
      { error: 'forbidden', ruleId: it.ruleId },
      { status: it.status || 403 },
    )
  },
})

// Next 16's proxy.ts defaults to the Node.js runtime — `runtime: 'nodejs'`
// is rejected here. Use this exact shape on Next 15 in middleware.ts and
// add `runtime: 'nodejs'` to the config object.
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Live examples: examples/next15-app (Next 15 + middleware.ts) and examples/next16-app (Next 16 + proxy.ts).

adapter

NestJS · @coraza/nestjs

Module + guard. Works with Express and Fastify platforms.

CorazaModule.forRoot(options)

module

Import into your root module. Registers CorazaGuard as APP_GUARD by default so every route is protected; opt out with globalGuard: false and apply @UseGuards(CorazaGuard) selectively.

signature
class CorazaModule {
  static forRoot(options: CorazaNestOptions): DynamicModule
  static forRootAsync(options: CorazaNestAsyncOptions): DynamicModule
}

interface CorazaNestOptions extends WAFConfig {
  globalGuard?: boolean           // default true
  ignore?: IgnoreSpec | false
  onBlock?: (i: Interruption) => HttpException
  onWAFError?: 'allow' | 'block'
}

Example — opt-in per route

import { Controller, Get, UseGuards } from '@nestjs/common'
import { CorazaGuard } from '@coraza/nestjs'

@Controller('billing')
export class BillingController {
  @UseGuards(CorazaGuard)
  @Get('invoice')
  invoice() {
    return { ok: true }
  }
}

Recipes

Staged rollout with detect-only

Enable CRS in detect mode, log, watch for false positives, then flip to block. Same ruleset both ways.

const mode = process.env.WAF_MODE === 'enforce' ? 'block' : 'detect'
const waf = await createWAF({ rules: recommended(), mode })
app.use(coraza({ waf }))

Trim CRS for Node-only APIs

Drop rule categories that don't apply to your workload. Keeps the core attack detection active; skips scanner-fingerprinting and rate-limit rules.

const waf = await createWAFPool({
  rules: recommended({
    excludeCategories: [
      'scanner-detection',
      'dos-protection',      // do rate limits upstream
      'outbound-data-leak',   // only matters with inspectResponse
    ],
  }),
  mode: 'block',
  size: os.availableParallelism(),
})

Fail-open for availability-critical endpoints

By default, a WAF crash returns 503. If you run a critical endpoint where a correctness/availability trade-off favors availability, flip onWAFError.

// NOT recommended for endpoints exposed to the public internet.
// See docs/threat-model.md before enabling.
app.use(coraza({ waf, onWAFError: 'allow' }))

Security

This is a WAF — correctness beats throughput, always. The adapter defaults are chosen accordingly:

DefaultBehaviorWhy
mode detect Safer first-run — log before you block. Flip to 'block' after tuning.
onWAFError block (503) A WAF crash must not become a bypass. Opt into 'allow' explicitly.
phase evaluation atomic bundle Phases 1 and 2 run in one WASM call, so the CRS anomaly-score evaluator at phase 2 always fires — even on body-less GET requests.
oversized fields clip, not throw Fields clip at the UTF-8 boundary. Throwing into the adapter's catch path would turn a crafted-oversized request into a bypass; clipping keeps the request evaluated.
ignore /_next/static, /public/, images, fonts, … Static assets have no user input — evaluating them is pure cost. Customize via ignore.routes / ignore.extensions (legacy skip: still mapped, removed at stable 0.1).
Known caveats
Full threat model + caveats (ReDoS via V8 Irregexp, Unicode case-insensitive, UTF-8 encoding, rxprefilter accuracy) are in docs/threat-model.md. Escape hatch: CORAZA_HOST_RX=off forces every @rx pattern through Go's linear-time regex instead of V8's backtracking engine.

Performance

Bench: k6 mixed traffic (50 VUs, 20 s, ~5% attacks), POOL=8, TinyGo WASM, full CRS. Your numbers will vary; ratios should hold.

4.8krps
POOL=8 · full CRS
37ms
p99 · blocks + passes
100%
attack block rate
170ms
WAF boot
Config RPS p99 (ms) Attack block rate
WAF off (baseline) 11,601 5.4
Full CRS, single WAF 1,061 61.9 100%
createWAFPool, size=8 4,857 37.4 100%

Two knobs move throughput in practice:

  • Pool sizecreateWAFPool({ size: os.availableParallelism() }). ~4.5× over a single WAF on an 8-core box.
  • Rule-class exclusionsrecommended({ excludeCategories: ['scanner-detection', 'dos-protection'] }). Typically saves 30–50% CPU per request. Don't drop attack classes (sqli, xss, rce).