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.

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, t } from "@nifrajs/core"

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 c.json({ received: bytes.byteLength })
})

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.
})

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.

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
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)) return c.json({ ok: false, error: "no_file" }, 400)

  // 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) return c.json({ ok: false, error: result.reason }, 400)
  //  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 c.json({ ok: true, url })
})

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
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
import { verifyWebhook } from "@nifrajs/core"

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) return c.json({ ok: false, error: r.reason }, 400)
  //  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 c.json({ 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.

TS
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 c.json({ 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, 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.

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

app
  // JWT: the algorithm allowlist is REQUIRED; alg:none and RSA/HMAC confusion are rejected; exp enforced.
  .use(jwt({ key: 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: 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 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.

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, 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 valuePatternscommonSecretPatterns covers bearer tokens, JWTs, emails, and a few well-known key formats, or supply your own:

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

// 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] }
Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT