Docs

Security & hardening

The pieces every production endpoint needs - a body-size cap for raw routes, real file-type validation, constant-time webhook verification, and idempotent retries - ship as first-party primitives. All are edge-safe (WebCrypto, no node:crypto) and run unchanged on Bun, Node, Deno, and Workers. For how these defaults stack up against Hono, Fastify, Express, and Elysia, see security posture, compared.

Responses that cannot leak more than they declare

A response schema is a lower bound: it says “at least these fields”, never “only these”. A handler returning a database row that satisfies it also ships every other column, and nothing points at it - TypeScript’s excess-property check does not reach a handler’s return position, and the client’s type reports the contract rather than the bytes. So the leak is invisible from both ends, and it can appear with no code change at all: add a column, and the next deploy ships it to browsers.

TS
import { server } from "@nifrajs/core/server"
import { responseContract } from "@nifrajs/core/response-contract"
import { t } from "@nifrajs/schema"

const PublicUser = t.object({ id: t.string(), name: t.string() })

// "warn" logs and changes nothing; "enforce" makes the contract the upper bound too.
// Not installing the plugin is "off" - and keeps the lane out of your bundle entirely.
export const app = server().use(responseContract("enforce", { extra: "strip" })).get(
  "/me",
  { response: PublicUser },
  async () => {
    // Every column, including the ones the contract never declared.
    const user = { id: "u1", name: "Ada", email: "a@b.c", passwordHash: "..." }
    return user
  },
)

// off                          -> 200 {"id":"u1","name":"Ada","email":"a@b.c","passwordHash":"..."}
// enforce                      -> 500 {"ok":false,"error":"internal_error"}
//                                 (t.object is closed; the log names email and passwordHash)
// enforce, { extra: "strip" }  -> 200 {"id":"u1","name":"Ada"}

Not installing the plugin is “off”, which is exactly today’s behaviour and keeps the lane out of your bundle rather than shipping a disabled branch to everyone. "warn" checks every response, logs the undeclared fields by name, and serves the payload unchanged - so turning it on in staging can never be the thing that broke production. "enforce" serializes the validated value instead of the raw result. Both cover a returned value, a status(...) result and a c.json(...) reply alike, whether the handler returns or throws it, a hook returns it early, or onError answers with it; only a hand-built Response (a redirect, a stream) passes unchecked. Install it before the routes it should cover: like idempotency(), the decision is made per route at registration.

By default, enforcement follows your schema’s own semantics, because Standard Schema exposes validate and no way to enumerate declared keys. A stripping schema (Zod, Valibot) yields a cleaned value, so the extra fields are dropped. A strict one (@nifrajs/schema’s t.object) reports them as issues, so the response becomes a 500 and the detail goes to the logger, never to the caller. { extra: "strip" } changes that for t schemas: they carry their JSON Schema, which does list the declared keys, so the payload is projected onto them - nested objects, arrays and records included - before it is validated. A union is projected when a value can meet only one of its branches: a single object branch, or object branches that all require one key holding literals no two of them share, such as ok: true and ok: false. Other unions are left to the validator. In "warn" mode the same option makes the log name the fields "enforce" would drop.

Stripping is silent by design. To see a field the contract does not declare - a new column - before someone asks where it went, pass logStripped: true with "enforce": the log names each stripped field, nested ones as user.email and array items as items[].cost, once per route for each distinct set of names. Values are never logged.

For a metric or an alert, pass onReport. It receives every response the contract has something to say about: violation (enforce mode, a 500 went out instead), warn (warn mode, sent anyway, with the undeclared keys in fields when they are the reason) and stripped (enforce mode, sent without fields). Each report carries the route’s method and pattern and the log line’s message, never a value. A hook that throws or rejects is logged once and changes no response.

TS
import { server } from "@nifrajs/core/server"
import { responseContract } from "@nifrajs/core/response-contract"

const counts = new Map<string, number>()

export const app = server().use(
  responseContract("enforce", {
    extra: "strip",
    // Every report, where the log speaks once per route. `route` is the pattern, so the keys stay bounded.
    onReport: ({ kind, method, route }) => {
      const key = `${kind} ${method} ${route}`
      counts.set(key, (counts.get(key) ?? 0) + 1)
    },
  }),
)

A merge()d server’s routes are covered by a contract the parent installed before the merge, as long as the route declares a response and its own server installed none. See what a parent’s use() covers.

The check itself is essentially free: with a compiled validator it measures in the ~100ns-per-response range, and a realistic middleware-carrying route benchmarks within noise of the same route with no contract at all, on Bun and Node alike. What a contracted route does give up is the bare-route fused lane - the check needs the handler’s value before it becomes bytes - and a route with any middleware, derive, or lifecycle hook has already left that lane. If a route looks like production, the contract costs nothing: declare it.

Bounded request bodies

Nifra caps the body of any schema-validated route at maxBodyBytes - an over-cap Content-Length is rejected before buffering, and a chunked body is aborted mid-stream. But a route that reads the body directly (raw bodies, file uploads, your own validation) bypasses that read path. c.boundedBody(maxBytes?) and c.boundedJson<T>(maxBytes?) extend the same cap to those routes.

TS
import { server } from "@nifrajs/core/server"

const app = server()

// A schema route is ALREADY bounded - the validated read enforces `maxBodyBytes`.
// But a raw-body / file / BYO-validation route reads the body directly, which
// `maxBodyBytes` does not cover. `c.boundedBody` caps that read:
app.post("/import", async (c) => {
  const bytes = await c.boundedBody(5 * 1024 * 1024) // cap THIS route at 5 MiB
  // Over-cap throws a flat 413; a malformed Content-Length a 400 - as control-flow
  // Responses (caught by the lifecycle like `throw redirect()`), so a handler can't
  // accidentally ignore the cap. The over-cap length is rejected BEFORE buffering;
  // a chunked / length-less body is aborted mid-stream once it crosses the cap.
  return { received: bytes.byteLength } // a returned object is serialized as JSON 200
})

app.post("/rpc", async (c) => {
  const body = await c.boundedJson<{ method: string }>() // default: the server's maxBodyBytes
  // …bad JSON → 400. Then validate `body` with your schema before trusting it.
  return { method: body.method }
})

Over-cap throws a flat 413, a malformed Content-Length a 400, bad JSON a 400 - thrown as control-flow Responses the lifecycle catches, so the cap can't be silently skipped. Pass a larger maxBytes for an upload route, a smaller one to tighten an endpoint.

The cap is enforced on the bytes actually delivered, never on the number the caller wrote in Content-Length. That distinction is the whole defense: an adapter that rebuilds a request from an event envelope, or any code assembling a Request by hand, can carry a header claiming 5 bytes over a payload of five megabytes. A runtime HTTP parser cannot lie that way, because it framed the body at the declared length itself, so nifra's ingress adapters mark their requests as runtime-framed and skip the recount entirely: listen() on Bun, toFetchHandler(app) on Workers and edge, @nifrajs/deno, and @nifrajs/node (which reads exact byte counts in the first place). Nothing to configure on any of them.

Only a request arriving through a path nifra ships no adapter for pays the extra pass over the body. If you serve an edge runtime by exporting the app directly rather than through toFetchHandler, server({ trustBodyFraming: true }) asserts that every app.fetch request came from the platform's own parser. It is an assertion about deployment topology, so do not set it on an app whose fetch is also called with requests built from untrusted input. It trusts the frame, never the cap: an over-cap declared length is still a 413, and a body with no Content-Length still goes through the streaming guard.

Prototype-poisoning defense

Every JSON body nifra parses - the schema-validated route and c.boundedJsonalike - is screened for the prototype-pollution shape: an own __proto__ key, or a constructor whose value carries a prototype. JSON.parsecreates these as ordinary data properties, and the damage lands later, when innocent code does { ...body }, Object.assign(target, body), or a deep merge and walks that key onto a real prototype. The screen is on by default.

TS
import { server } from "@nifrajs/core/server"

// Default is "reject". A JSON body with an own `__proto__` key - or a `constructor`
// carrying a `prototype` - is the exact shape that turns a later innocent { ...body }
// merge or Object.assign into prototype pollution. It answers the SAME flat 400 as
// malformed JSON, so an attacker learns nothing from the response.
const app = server({ protoPoisoning: "reject" }) // "strip" | "ignore" also available

app.post("/profile", async (c) => {
  const body = await c.boundedJson<{ name: string }>() // and the schema-route path
  // Reached only for a clean payload. Under "strip" the offending keys are deleted and
  // the handler sees the cleaned object; under "ignore" the body passes through as-is.
  return { name: body.name }
})

protoPoisoning is a server option with three settings. "reject" (the default) answers the same flat 400 as malformed JSON - indistinguishable on the wire, so a probe learns nothing. "strip" deletes the offending keys and hands the handler the cleaned value, siblings intact. "ignore" parses as-is, for a route you are sure never merges body input into another object. A string value of "__proto__" is legal data and never triggers - only an own key of that name does. A WebSocket route with a messageSchema parses each frame under the same policy; under "reject" a poisoned frame reaches onInvalidMessage as invalid JSON.

The check is sound against escape smuggling: a __proto__-spelled key parses to the same own property, so it is caught the same way. And it is cheap: one walk over the objects the parse already built, with no reviver and no pre-scan of the raw text.

Forms and file uploads - t.form

A multipart/form-data body is declared like any other: text fields and files side by side, validated before the handler runs, typed in c.body. The constructors live on the t of @nifrajs/schema/form - the same builder as @nifrajs/schema plus t.file and t.form - so an app that takes no uploads ships none of this.

TS
import { server } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema/form"

const app = server()

app.post(
  "/listings",
  {
    body: t.form(
      {
        title: t.string({ minLength: 1, maxLength: 120 }),
        price: t.integer({ minimum: 0 }), // form values arrive as text; "1200" becomes 1200
        cover: t.file({ maxBytes: 5_000_000, accept: ["image/png", "image/jpeg"] }),
        photos: t.array(t.file({ maxBytes: 5_000_000, accept: ["image/*"] }), { maxItems: 8 }),
        floorPlan: t.optional(t.file({ accept: ["application/pdf"] })),
      },
      { maxFiles: 10, maxFields: 20 },
    ),
    bodyLimit: 50_000_000, // the default body cap is 1 MB: raise it on the routes that take files
  },
  (c) => {
    const { title, cover, photos } = c.body // cover: File, photos: File[], floorPlan?: File
    // Name the stored object yourself. `cover.name` is whatever the client typed.
    const key = `${crypto.randomUUID()}.${cover.type === "image/png" ? "png" : "jpg"}`
    return { title, key, photos: photos.length }
  },
)

t.file checks the size before it reads a byte, and accept is matched against the file’s leading bytes, never the type the client claimed: a script renamed photo.png fails validation. With accept set, the file the handler receives carries the detected type. A type that has no signature to check (text/csv, image/svg+xml) is refused when the schema is built, not silently let through. Text fields are coerced from their string form, a repeated field becomes a list, and a field the form does not declare fails validation unless additionalProperties is set.

The body is bounded before any of it is validated. The route’s bodyLimit caps the whole request; maxFields (100), maxFiles (10), maxFieldBytes (64 KiB) and maxFileBytes cap what it may carry. A request over one is answered 413 with the limit it crossed (payload_too_large, too_many_parts, too_many_fields, too_many_files, field_too_large, file_too_large); a body that is not a well-formed form is a 400 invalid_multipart, and any other content type a 415. A body that ends before its closing delimiter is refused rather than read as the parts that did arrive. Field names are screened by the same protoPoisoning policy as JSON keys.

The typed client needs nothing extra: a body that holds a File or Blob goes out as a form.

TS
// doc-check: skip - fragment: `app` is the server above; `input` is a file input on your page.
import { client } from "@nifrajs/client"

const api = client<typeof app>("https://api.example.com")
const files = [...(input.files ?? [])]

// A body that holds a File is sent as multipart/form-data. The call is typed like any other.
const { data, error } = await api.listings.post({
  title: "Two rooms by the park",
  price: 1200,
  cover: files[0],
  photos: files.slice(1),
})

Bringing your own validator? multipartBody marks any Standard Schema as a form body and takes the same limits. t.form has already done this for itself.

TS
// doc-check: skip - fragment: `app` is your server; zod is your own dependency.
import { multipartBody } from "@nifrajs/core/multipart"
import { z } from "zod"

const Upload = z.object({ title: z.string(), attachment: z.instanceof(File) })

// Any Standard Schema can validate a form. `multipartBody` is what tells the route to read one.
app.post("/attachments", { body: multipartBody(Upload, { maxFiles: 1 }) }, (c) => c.body.title)

What a validated file does and does not tell you:

  • The signature is all that was checked. A file can open as a PNG and still carry another format behind it, and application/zip is also what a .docx or .xlsx is. Re-encode images you will serve back, and scan what you will open.
  • file.name is the client’s. Path separators and control characters are removed, and that is all. Generate the storage key; never build a path from the name.
  • Without accept, file.type is the client’s too. Some runtimes derive it from the file extension. Only a type that accept matched is one the bytes proved.
  • Serve uploads as downloads. Send content-disposition: attachment and x-content-type-options: nosniff, from a separate origin where you can.
  • A form post is a simple cross-origin request. A browser sends multipart/form-data, urlencoded and text/plain with cookies and no preflight. A body-schema route refuses one whose Origin is another site’s with 403 before reading it; list a site that must post forms to the app in the server’s formOrigins. A request without an Origin passes, so a cookie-authenticated upload route still needs csrf() like any other state change.
  • The form is held in memory. Budget roughly twice bodyLimit per request in flight. Past tens of megabytes, hand the client a presigned URL and let it upload to storage directly.

An input nobody filled in is not a file: a zero-byte file part, or an empty text part in a file field, counts as absent, so it fails a required field and leaves an optional one undefined. A required list nobody filled in is []; bound it with minItems if one entry is mandatory. A browser checkbox submits "on", which is not a boolean - give the input value="true". And a form with no required file also accepts the same fields as JSON or application/x-www-form-urlencoded, so one route serves a plain HTML form and a script.

In tests, send forms through inProcessClient or testClient: they encode the form first, so the request arrives with the Content-Length a network peer would send. A Request built straight from a FormData and passed to app.fetch has none, and on Node 26 the runtime raises an unhandled rejection when the app stops reading such a body early, as it does for one over the limit.

Other body formats - bodyParser

A route reads JSON and urlencoded bodies and answers 415 to everything else. bodyParser from @nifrajs/core/body-parser names the other media types one route reads and the function that decodes them. What it decodes is validated by the same schema, so the handler sees one typed body whatever format it arrived in.

TS
// doc-check: skip - fragment: `app` is your server; `Pipeline` is your schema; yaml is your own dependency.
import { bodyParser } from "@nifrajs/core/body-parser"
import { parse } from "yaml"

const utf8 = new TextDecoder("utf-8", { fatal: true })

app.post(
  "/pipelines",
  {
    body: bodyParser(Pipeline, {
      types: ["application/yaml"],
      // Handed the bytes, already within bodyLimit. Bound the decoder too: alias count, nesting.
      parse: (bytes) => parse(utf8.decode(bytes), { maxAliasCount: 0 }),
    }),
  },
  (c) => c.body.name, // validated by Pipeline, whatever format it arrived in
)

The route opts in, not the app: a parser registered once for every route would widen what each of them accepts. Before parse runs, the request’s media type has matched one of types in full (case folded, parameters set aside) and the body has been read under the route’s bodyLimit, counted on the bytes delivered. After it, and before the schema:

  • A parser that throws is a 400. The answer is invalid_body; the parser’s own message is not sent.
  • The value must be a tree. An object or array reached twice is what an alias or a cycle decodes to, and a few bytes of it can describe a value that takes minutes to walk. It is refused with a 400 under every protoPoisoning setting.
  • The protoPoisoning policy applies. A __proto__ key, a constructor that carries a prototype, and an object whose prototype the decoder replaced with data are rejected or stripped, as for JSON. An instance of a class, such as a Date or a Uint8Array, is passed through without being looked into.

What stays yours is the decoder’s own limits: nesting depth, alias expansion, and the size of a number or a string are spent inside parse, before anything above can look. Configure it for untrusted input.

types takes full media types only, and refuses four: JSON, urlencoded and multipart already have readers, and text/plain is a body a browser sends from any site with the user’s cookies and no preflight. Every type it does accept is one a browser must preflight, so your CORS policy sees the request first. The generated OpenAPI document lists each type under the operation’s request body. The typed client keeps sending JSON, which the route still reads; send another format with fetch.

File uploads - @nifrajs/uploads

A dependency-free package for the upload-hardening basics. validateUpload enforces a size cap and sniffs the real type from magic bytes - never the client-set Content-Type, which is trivially forged - against an optional allow-list. An oversized Blob is rejected by its .size before it's ever buffered.

TS
// doc-check: skip - fragment: `app`, `save`, `id`, and `env` are your application's.
import { validateUpload, signDownloadUrl } from "@nifrajs/uploads"

app.post("/avatar", async (c) => {
  const form = await c.req.formData()
  const file = form.get("file")
  if (!(file instanceof Blob)) { c.set.status = 400; return { ok: false, error: "no_file" } }

  // Size cap + REAL type by magic bytes - a .exe renamed .png (or a spoofed
  // Content-Type) is caught, because the bytes win. An oversized Blob is rejected
  // by its .size BEFORE it's buffered into memory.
  const result = await validateUpload(file, {
    maxBytes: 2_000_000,
    accept: ["image/png", "image/jpeg"], // exact, or "image/*"
  })
  if (!result.ok) { c.set.status = 400; return { ok: false, error: result.reason } }
  //  reason: "too_large" | "empty" | "unrecognized" | "type_not_allowed"

  await save(result.bytes, `${id}.${result.ext}`) // result.mime / .ext are trustworthy

  // Hand back a short-TTL, tamper-evident URL (HMAC over path + expiry):
  const url = await signDownloadUrl(`/files/${id}`, env.FILE_SECRET, { expiresInSeconds: 300 })
  return { ok: true, url } // a returned object is serialized as JSON 200
})

Pair it with c.boundedBody to also bound the read: cap the read, then validate the buffered bytes. detectFileType(bytes) is exposed standalone too (returns { mime, ext } or null), covering common image / A-V / archive types.

signDownloadUrl / verifyDownloadUrl mint short-TTL, tamper-evident download links (HMAC-SHA256 over the path + expiry, constant-time verify). And stripImageMetadata drops EXIF/GPS by re-encoding the image - through any @nifrajs/image backend, with no dependency on it:

TS
// doc-check: skip - fragment: continues the upload handler above (`result.bytes`).
import { stripImageMetadata } from "@nifrajs/uploads"
import { bunImageBackend } from "@nifrajs/image/backends"

// Drop EXIF/GPS by re-encoding through any @nifrajs/image backend. @nifrajs/uploads keeps
// ZERO dependency on @nifrajs/image - the backend is passed in (structural type), so this
// also works with sharpImageBackend(sharp) on Node or wasmImageBackend(...) on the edge.
const clean = await stripImageMetadata(result.bytes, bunImageBackend())

Webhooks - verifyWebhook

The cardinal webhook rule: verify before you parse. A handler that JSON.parses the body before checking the signature is acting on an unauthenticated payload. verifyWebhook reads the raw body bounded, verifies the HMAC, and hands back the verified text for you to parse with your own schema.

TS
// doc-check: skip - fragment: `app`, `env`, `StripeEvent`, and the rotation keys are your application's.
import { verifyWebhook } from "@nifrajs/core/webhook"

app.post("/webhooks/stripe", async (c) => {
  // Reads the raw body BOUNDED (DoS guard), verifies the HMAC CONSTANT-TIME, and only
  // then returns the payload. Never JSON.parse a webhook before the signature checks out.
  const r = await verifyWebhook(c.req, env.STRIPE_WEBHOOK_SECRET, { provider: "stripe" })
  if (!r.ok) { c.set.status = 400; return { ok: false, error: r.reason } }
  //  reason: "missing_signature" | "invalid_signature" | "timestamp_out_of_tolerance"
  //        | "malformed_signature" | "payload_too_large" | "invalid_content_length"

  const event = StripeEvent.parse(JSON.parse(r.payload)) // validate at the trust boundary
  // …handle event… (pair with idempotency below so a redelivery doesn't double-process)
  return { ok: true }
})

// GitHub (sha256=…hex), or any provider via the generic preset:
await verifyWebhook(c.req, env.GH_SECRET, { provider: "github" })
await verifyWebhook(c.req, [next, current], {            // an array accepts either during a rotation
  header: "x-signature", encoding: "base64", prefix: "v1=",
})

Verification is constant-time - the provider's signature goes straight into crypto.subtle.verify, so a wrong signature can't be discovered byte-by-byte through timing. Presets cover Stripe (parses t=…,v1=… and enforces a 5-minute replay window on the signed timestamp) and GitHub (sha256=…); the generic preset takes an explicit header, encoding ("hex", "base64" or "base64url", padded or not), and prefix for anything else. A signature that does not decode is refused as malformed_signature, never thrown. Pass an array of secrets to accept either during a key rotation.

Idempotency - idempotency() middleware

A dropped connection or an impatient double-tap shouldn't double-charge a card. With an Idempotency-Key header, a retried unsafe request replays the first response instead of re-running the side effect. The key is claimed in onRequest, so a concurrent retry gets a 409; the stored response is replayed from beforeHandle, after the route's authentication accepts the retry, so a credential revoked since the first request gets the route's current answer. Declare routes after .use(idempotency()): beforeHandle is order-scoped, and a route declared earlier replays from onRequest.

TS
// doc-check: skip - fragment: `app`, `chargeCard`, and `id` are your application's.
import { idempotency, MemoryIdempotencyStore } from "@nifrajs/middleware"

// Dev / single-instance. In production use a SHARED store (Redis, etc.) with an atomic
// claim - MemoryIdempotencyStore throws under NODE_ENV=production unless you opt in.
app.use(idempotency({ store: new MemoryIdempotencyStore() }))

app.post("/charge", async (c) => {
  await chargeCard(/* … */) // the side effect
  return { ok: true, id }
})

// A client retrying POST /charge with the same `Idempotency-Key` header gets the FIRST
// response replayed (`Idempotent-Replayed: true`) - the charge runs once. A concurrent
// retry, while the first is still in flight, gets 409 { error: "idempotency_in_progress" }.
// Transient 5xx are NOT cached (a failed call stays retryable).
  • Production needs a shared store. MemoryIdempotencyStore is per-instance and refuses to start under NODE_ENV=production unless you pass { allowInProduction: true }. Implement IdempotencyStore over Redis (etc.) with an atomic claim (SET key NX PX) so two retries can't both proceed.
  • Pair it with a DB uniqueness constraint. The middleware stops the retry; the constraint is the source of truth for genuinely-concurrent distinct requests. Belt and braces - the constraint is the belt.
  • Set-Cookie is never cached or replayed. A session cookie is caller-specific; replaying it to a second caller (key collision or abuse) would leak/fixate a session. The first caller still gets their cookie - replays just don't carry it.
  • Caching buffers the response body, so apply it to JSON/API routes, not streaming SSR responses. Transient 5xx aren't cached, and neither are 401, 403, 408, 409, 425, or 429: each says the call never ran, so it stays retryable under the same key.
  • The key is not bound to the body: a key reused with a different body replays the first answer. A route that needs that check declares schema.idempotency, which fingerprints the request and refuses a mismatched reuse with 409.

Edge gating - jwt, csrf, ipRestriction, bodyLimit

@nifrajs/middleware ships the request-gating set, applied with app.use(). Every one is constant-time where it compares secrets and fails closed by default.

TS
import { server } from "@nifrajs/core/server"
import { jwt, csrf, ipRestriction, bodyLimit } from "@nifrajs/middleware"

const app = server()
  // JWT: the algorithm allowlist is REQUIRED; alg:none and RSA/HMAC confusion are rejected; exp enforced.
  .use(jwt({ key: process.env.JWT_SECRET!, algorithms: ["HS256"], issuer: "my-app" }))
  // Signed double-submit CSRF (HMAC) + Origin/Referer check on unsafe methods. Secret must be >= 32 bytes.
  .use(csrf({ secret: process.env.CSRF_SECRET! }))
  // Allow/deny by IPv4/IPv6 + CIDR, judged against c.clientIp: the socket peer, or the chain your
  // server({ clientIp }) trust declaration names. FAILS CLOSED with no caller IP; a raw
  // X-Forwarded-For is never believed.
  .use(ipRestriction({ allow: ["10.0.0.0/8", "::1"] }))
  // Reject oversized bodies at the EDGE by Content-Length, before routing - fails closed (411) on a
  // length-less body. (The schema / c.boundedBody cap is the read-time guard; this is the cheap pre-filter.)
  .use(bodyLimit({ maxBytes: 1_000_000 }))
  • jwt - WebCrypto verification with a required algorithms allowlist; alg:none and RSA/HMAC confusion are rejected, exp/nbf/iss/aud are checked. Rotating keys via jwks({ url }) (HTTPS-only, cached). Read claims with auth.requireClaims(c.req).
  • csrf - signed double-submit token (HMAC, secret ≥ 32 bytes) plus an Origin/Referer check on unsafe methods; both the token match and signature are verified constant-time.
  • ipRestriction - IPv4/IPv6 exact + CIDR allow/deny. It fails closed when no trustworthy client IP can be derived, and never trusts X-Forwarded-For unless you set trustedProxies to the number of proxies in front of the app.
  • bodyLimit - a cheap Content-Length pre-filter that rejects oversized bodies before routing (fails closed with 411 on a length-less body). The read-time guard above (c.boundedBody / schema cap) remains the source of truth.

Security response headers - securityHeaders

securityHeaders() sets a safe-by-default response header set on every response, errors and 404s included. Three are always on - X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: no-referrer. The cross-origin isolation headers (COOP/COEP/CORP), Permissions-Policy, Content-Security-Policy, and HSTS are opt-in, because each one can break a working app (embedding, popups, HTTP) and so is a deliberate choice, never a silent default.

TS
import { server } from "@nifrajs/core/server"
import { securityHeaders } from "@nifrajs/middleware"

// Always on (covering errors and 404s too): X-Content-Type-Options: nosniff,
// X-Frame-Options: DENY, Referrer-Policy: no-referrer. The rest are opt-in - each is a
// deliberate cross-origin decision, so nifra never turns them on behind your back:
const app = server().use(securityHeaders({
  crossOriginOpenerPolicy: "same-origin",      // isolate the browsing-context group
  crossOriginResourcePolicy: "same-origin",    // block cross-origin embedding of your responses
  crossOriginEmbedderPolicy: "require-corp",   // enable crossOriginIsolated (SharedArrayBuffer, …)
  permissionsPolicy: "camera=(), geolocation=()",
  contentSecurityPolicy: "default-src 'self'",
  hsts: { maxAge: 63072000, includeSubDomains: true, preload: true }, // opt in once HTTPS-only
}))

Every value is fixed at construction, so the headers are declared statically rather than written by a response hook - an app whose response middleware is only this keeps the fused native response lanes. A route that sets one of these names itself keeps its own value. A group can apply its own configuration over the app's - admin.use(securityHeaders({ contentSecurityPolicy: "default-src 'none'" })) inside app.group("/admin", ...) - and each header it sets replaces the app's value on the group's routes only.

A fixed Content-Security-Policy here cannot allow nifra's inline scripts by nonce. For a strict script-src on web pages, give createWebApp a csp from createCspPolicy: it allows the framework's constant scripts by hash and adds a nonce only to a page that needs one, so every other page sends the same header and stays cacheable under withISR or a CDN. See A strict CSP that still caches.

Route assurance - prove every route is guarded

Installing security middleware is not the same as proving it covers every route. Nifra's official auth, CSRF, body-limit, rate-limit, idempotency, IP-restriction, and security-header modules publish reflection-safe evidence at the hook where they enforce it. A policy then classifies every route and fails closed when evidence is missing or forbidden.

TS
// doc-check: skip - configuration file imports your application backend.
// nifra.assurance.ts
import { defineAssuranceConfig, NIFRA_ASSURANCE } from "@nifrajs/core/assurance"
import { app } from "./backend.ts"

export default defineAssuranceConfig({
  source: app,
  policy: {
    // First match owns the route: put narrow exceptions before broad defaults.
    rules: [
      { name: "health", match: { paths: ["/health"] }, require: [],
        forbid: [NIFRA_ASSURANCE.AUTHENTICATED] },
      // Every method but these, so a custom one such as PURGE is a mutation too.
      { name: "mutation", match: { exceptMethods: ["GET", "HEAD", "OPTIONS", "WS"] },
        require: [NIFRA_ASSURANCE.AUTHENTICATED, NIFRA_ASSURANCE.CSRF,
          NIFRA_ASSURANCE.BODY_BOUNDED] },
      { name: "read", match: { methods: ["GET", "HEAD"] },
        require: [NIFRA_ASSURANCE.AUTHENTICATED] },
      // WebSocket routes reflect under the pseudo-method "WS".
      { name: "socket", match: { methods: ["WS"] },
        require: [NIFRA_ASSURANCE.AUTHENTICATED] },
    ],
  },
})

// CI: nifra assure              # human diagnostics
// CI: nifra assure --json       # complete machine-readable report

Evidence follows real lifecycle semantics: pre-routing and response hooks cover the whole app, while authentication and other order-scoped hooks cover only routes registered after them. Method and path filters prevent a narrow guard from claiming broader coverage. The evaluation runs only through reflection or nifra assure, so requests pay no assurance cost.

WebSocket routes are reflected under the pseudo-method "WS", so a policy classifies every socket too. A handshake runs the auth guards registered before the socket and the app's request hooks, so a socket carries authentication and rate-limit evidence from them. It never claims evidence whose enforcement its message handlers bypass, such as a durable command journal. A socket declares its effects with app.ws(path, { capabilities }), and capability assurance checks them against the module that registers it.

Effect assurance - declared capability versus provenance

Authentication does not reveal what a route can do. Capability assurance compares an exact route declaration against every approved effect import reachable through that route's local module graph. Static, dynamic, require, and re-export edges are scanned; raw provider imports fail nifra check. Runtime beacons add denial at owned adapters, but are never treated as a substitute for static provenance.

TS
// doc-check: skip - combines route and assurance-config excerpts.
// route: exact effect declaration + correlated execution at the owned adapter seam
import { executeCapability } from "@nifrajs/core/capabilities"

app.aroundCapability(async (effect, next) => {
  // Ask an entitlement service or short-lived approval gate using token-only metadata.
  // effect.signal aborts on request cancellation or the interceptor timeout.
  if (!policyAllows(effect.capability, effect.target)) return // deny fail-closed
  await next()
}, { timeoutMs: 5_000 })

app.post("/orders", { capabilities: ["db.write"] }, async (c) => {
  return executeCapability(
    c,
    "db.write",
    { target: "repo:orders" },
    ({ signal }) => orders.write(c.body, { signal }),
  )
})

// in nifra.assurance.ts, alongside policy:
capabilities: {
  definitions: [
    { id: "db.read", zone: "domain", access: "read" },
    { id: "db.write", zone: "domain", access: "write", idempotency: "request" },
    { id: "telemetry.write", zone: "operational", access: "write" },
  ],
  provenance: {
    // Use effect-specific facades. A broad module that mixes reads/writes cannot prove either.
    imports: [
      { specifier: "@app/db/read", capabilities: ["db.read"] },
      { specifier: "@app/db/write", capabilities: ["db.write"] },
    ],
    forbiddenImports: [
      { specifier: "postgres", reason: "use the tenant-scoped DB facade" },
    ],
  },
}

// developer: nifra capabilities snapshot
// CI:        nifra check && nifra assure && nifra capabilities check

Domain writes on GET/HEAD are hard violations. Each write definition may require request idempotency or durable command/provider-key evidence. The lockfile contains only method, path, and capability tokens-no payloads or tenant data-and CI never rewrites it.

Durable approval, compensation, and reconciliation

The durable execution subpath turns the capability boundary into a crash-visible workflow. Approval resumes are HMAC-signed, expire, bind to the tenant, principal, capability, target, and digest, and are consumed atomically once. The effect journal marks execution before the provider call, so a crash after an external commit remains ambiguous for reconciliation instead of being retried blindly. Typed sagas persist compensation arguments separately from the sealed token-only ledger and compensate committed steps in reverse order with retry/backoff state. One resume or compensate call owns a saga at a time through a lease every transition renews (leaseMs, default 5 minutes): a second call meanwhile throws SagaConcurrencyError and changes nothing, and a call that died is taken over once its lease lapses.

TS
// doc-check: skip - durable store implementations are deployment-specific.
import {
  createApprovalCoordinator,
  createDurableEffectJournal,
  createSagaEngine,
} from "@nifrajs/core/durable-execution"
import { effectTracing } from "@nifrajs/otel/effects"

const effects = effectTracing({ exporter })
app.use(effects)

const approval = createApprovalCoordinator({
  store: durableApprovalStore, // must declare durability: "durable"
  secret: approvalHmacKey,     // 32+ random bytes, stored separately
})
const journal = createDurableEffectJournal({ store: durableEffectStore })

await executeCapability(c, "payments.charge", {
  target: "provider:stripe",
  digest,
  journal,
  approval: {
    gate: approval,
    tenantId: c.principal.tenantId,
    principalId: c.principal.userId,
    resumeToken,
  },
}, ({ effectId, signal }) => payments.charge(input, { idempotencyKey: effectId, signal }))

const sagas = createSagaEngine({
  store: durableSagaStore,
  observer: effects.observer,
})

Already built in

These add to Nifra's standing defaults: strict-by-default schema validation (unknown fields rejected), SSR serialization that escapes every inline-script value, __Host-/__Secure- cookie-prefix enforcement (a cookie whose attributes violate its prefix contract fails at serialization rather than being silently dropped by the browser), signed-cookie sessions + CSRF + route guards (@nifrajs/auth), bearer/apiKey auth + a shared-store rate limiter (@nifrajs/middleware), and a hardened image-resize endpoint (@nifrajs/image/server).

Redacting logs

The built-in jsonLogger redacts values under sensitive keys (password, authorization, token, …) by default. For secrets that land in a value or the message itself (e.g. an err.message that embeds a token), pass opt-in valuePatterns - commonSecretPatterns covers bearer tokens, JWTs, emails, and a few well-known key formats, or supply your own:

TS
import { server, jsonLogger, commonSecretPatterns } from "@nifrajs/core/server"

// Key-name redaction is always on; valuePatterns adds opt-in value + message scanning.
const app = server({
  logger: jsonLogger(undefined, { valuePatterns: commonSecretPatterns }),
})

// logger.error("auth failed for user@example.com with Bearer abc.def")
//   → { ...,"message":"auth failed for [REDACTED] with [REDACTED]" }
// Add your own: { valuePatterns: [...commonSecretPatterns, /\bord_[a-z0-9]+/g] }