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.
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")).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 -> {"id":"u1","name":"Ada","email":"a@b.c","passwordHash":"..."}
// enforce -> {"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. Install it before the routes it should cover: like idempotency(), the decision is made per route at registration.
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. Both are what you already declared about extra fields.
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.
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.
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.
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 on the common path - a clean body pays a substring pre-scan only; the deep walk runs solely when the raw text actually contains a suspect token, so an honest payload is never charged for the tree it does not have.
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.
// 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:
// 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.
// 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, and prefix for anything else. 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. It short-circuits in onRequest, before the handler.
// 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.
MemoryIdempotencyStoreis per-instance and refuses to start underNODE_ENV=productionunless you pass{ allowInProduction: true }. ImplementIdempotencyStoreover 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-Cookieis 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
5xxaren't cached, so a failed call stays retryable.
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.
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. FAILS CLOSED with no trusted client IP; X-Forwarded-For is ignored
// unless trustedProxies > 0 (set it to the number of proxies you actually run in front of the app).
.use(ipRestriction({ allow: ["10.0.0.0/8", "::1"], trustedProxies: 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 requiredalgorithmsallowlist;alg:noneand RSA/HMAC confusion are rejected,exp/nbf/iss/audare checked. Rotating keys viajwks({ url })(HTTPS-only, cached). Read claims withauth.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 trustsX-Forwarded-Forunless you settrustedProxiesto the number of proxies in front of the app.bodyLimit- a cheapContent-Lengthpre-filter that rejects oversized bodies before routing (fails closed with411on 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.
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.
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.
// 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] },
{ name: "mutation", match: { methods: ["POST", "PUT", "PATCH", "DELETE"] },
require: [NIFRA_ASSURANCE.AUTHENTICATED, NIFRA_ASSURANCE.CSRF,
NIFRA_ASSURANCE.BODY_BOUNDED] },
{ name: "read", match: { methods: ["GET", "HEAD"] },
require: [NIFRA_ASSURANCE.AUTHENTICATED] },
],
},
})
// CI: nifra assure # human diagnostics
// CI: nifra assure --json # complete machine-readable reportEvidence 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.
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.
// 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 checkDomain 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.
// 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:
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] }