Skip to content

Search docs

Jump between documentation pages.

Browse docs

Security

Bad defaults are bugs. DaloyJS separates core-enforced guardrails from first-party security middleware so the dangerous things are blocked by default and the deployment-specific things stay explicit.

Core guardrails protect every app by default. First-party middleware adds the policy that depends on your routes, users, and deployment.
Two layers of defense
Core-enforced guardrailsarmed by default, no middleware call required
bodyLimitBytessafeJsonParsesanitizeHeaderValuepath-traversal rejectrequestTimeoutMs405 + Allowprod 5xx redaction
First-party security middlewareexplicit, because policy is a deployment decision
secureHeaders()cors()csrf()rateLimit()session()bearerAuth()
The dangerous things are blocked in the core without any setup. The deployment-specific things (CSP, CORS origins, session secrets, CSRF rollout) stay explicit middleware you opt into.

What the core enforces

These checks happen in App or the runtime adapter itself. Applications get them without calling any middleware.

Every request runs the gauntlet
  1. 01ingressIncoming requestmethod, path, headers, body
  2. 02size capBody-size limitContent-Length over cap to 413
  3. 03parseHardened JSON parsestrips __proto__ / constructor
  4. 04routeRouter + method check.. resolved to canonical path; bad method 405
  5. 05handlerYour typed handlerruns under requestTimeoutMs
A request only reaches your handler after clearing the body cap, the prototype-pollution-safe parser, and the path/method guards. Anything that fails a guard is rejected by the core before your code runs.
ThreatBuilt-in behavior
Body-size DoSStreamed read, hard cap (default 1 MiB), Content-Length checked first -> 413.
Prototype pollutionsafeJsonParse strips __proto__, constructor, prototype via reviver.
Header / response splittingsanitizeHeaderName / sanitizeHeaderValue reject CRLF + NUL.
Path traversalDot-segments (. / ..) are resolved to a canonical path before route matching, and empty // segments are refused. Routes match exact strings, so there is no directory to escape into.
Slow-loris / hung handlersrequestTimeoutMs (default 30s) returns 408 and fires ctx.request.signal for cooperative teardown; the Node adapter also sets socket timeouts.
Unsupported content typesRoutes with body schemas reject non-allowed content-types -> 415.
Method confusionReal 405 with Allow header, never a misleading 404.
Information disclosure (5xx)Production mode strips detail from 5xx problem+json automatically.

First-party security middleware

These are part of DaloyJS and documented together, but they stay explicit because CSP, CORS, rate-limit keys, session secrets, and CSRF rollout are deployment decisions.

ts
import {
  requestId,
  secureHeaders,
  cors,
  rateLimit,
  bearerAuth,
  timing,
} from "@daloyjs/core";

app.use(requestId());           // x-request-id propagation
app.use(secureHeaders());       // CSP, HSTS, X-Frame-Options, COOP, CORP, no-sniff ...
app.use(cors({                  // explicit allowlist; never * with credentials
  origin: ["https://app.example.com"],
  credentials: true,
  methods: ["GET", "POST"],
}));
app.use(rateLimit({             // global by default; add keyGenerator or trusted proxy headers for per-client limits
  windowMs: 60_000,
  max: 120,
}));
app.use(timing());              // Server-Timing header for observability

Put rateLimit() or loginThrottle() before bearer/basic/JWK/mTLS auth when it must count failed credentials. The limiter will still spend the attempt and return 429 after the cap even though auth rejects in preBody; DaloyJS does not read a declared request body to do it. A custom key generator on that path should use raw request data or state populated by an earlier preBody hook.

The official starters wire these in for you: Node, Bun, and Deno enable secureHeaders(), requestId(), and rateLimit(); Cloudflare Worker and Vercel enable secureHeaders() and requestId() plus tighter edge-friendly body and timeout limits.

Start with the middleware below unless you have a concrete reason to change it. This keeps risky choices explicit and consistent.

TargetRecommended baseline
Node / Bun / Deno APIrequestId(), secureHeaders(), rateLimit(), and cors() when the API is cross-origin.
Cloudflare WorkersrequestId() and secureHeaders() by default; use cors() only when needed, and prefer an external/shared limiter over the in-memory default when traffic spans many isolates.
VercelrequestId() and secureHeaders() by default; add cors() only when needed, and use a shared limiter if you need durable counters across regions.
Cookie-authenticated appAdd session() plus csrf() on top of the baseline so mutating routes are protected against cross-site form and fetch attacks.
Behind a trusted reverse proxyKeep the baseline, then configure rateLimit() with an explicit keyGenerator or set trustProxyHeaders: true only after the proxy strips and rewrites forwarding headers.

csrf() for state-changing routes

Use csrf() to protect mutating endpoints. Two strategies are supported:

  • Double-submit cookie (default): sets a token cookie on safe requests, requires the same value on the x-csrf-token header for unsafe methods, and rejects mismatches with a timing-safe 403.
  • Fetch Metadata (strategy: "fetch-metadata") - tokenless protection that relies on the modern Sec-Fetch-Site header. No cookie round-trip; no HTML rendering coupling. Recommended for new browser-facing apps.
ts
import { csrf } from "@daloyjs/core";

// Classic double-submit cookie (default).
app.use(csrf());

// Tokenless Fetch-Metadata protection (recommended for browser-facing apps).
app.use(csrf({
  strategy: "fetch-metadata",
  allowedOrigins: ["https://app.example.com"],
}));

secureHeaders() defaults

text
content-security-policy: default-src 'self'; frame-ancestors 'none'
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
permissions-policy: camera=(), microphone=(), geolocation=(), clipboard-write=()
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin

If you need a different CSP, want to disable HSTS in local development, or need a looser permissions policy, pass options to secureHeaders() explicitly. The legacy X-XSS-Protection: 0 header is opt-in via xssProtection: true for deployments that want to explicitly disable old browser XSS filters.

CSP with per-request nonces & Trusted Types

secureHeaders() can build the CSP from a directive map and inject a fresh per-request nonce into script-src, script-src-elem, style-src, and style-src-elem, plus emit require-trusted-types-for 'script' for runtime DOM XSS hardening. The nonce is exposed at ctx.state.cspNonce so handlers can render it into <script nonce="..."> tags.

ts
import { secureHeaders } from "@daloyjs/core";

app.use(secureHeaders({
  contentSecurityPolicy: {
    directives: {
      "default-src": "'self'",
      "script-src": "'self'",
      "style-src": "'self'",
      "img-src": ["'self'", "data:"],
    },
    nonce: true,
    trustedTypes: { policies: ["default"] },
  },
}));

app.get(
  "/page",
  {
    operationId: "page",
    responses: { 200: { description: "ok" } },
  },
  // Return the HTML yourself so the secureHeaders nonce CSP is the one that ships.
  async ({ state }) => ({
    status: 200,
    body: `<!doctype html>
<script nonce="${state.cspNonce}">
  // inline bootstrap is allowed only via this fresh nonce
</script>`,
    headers: { "content-type": "text/html; charset=utf-8" },
  }),
);

Do not render this page with htmlResponse() from @daloyjs/core/docs: that helper ships its own Content-Security-Policy (tuned for the Swagger / Scalar docs UIs, with 'unsafe-inline') and would override the strict nonce CSP above, so the nonce would no longer be the thing gating inline scripts. Keep htmlResponse() for your API-docs route, and return your own Response body for nonce-protected pages.

Auth

ts
import { bearerAuth, basicAuth, timingSafeEqual } from "@daloyjs/core";

// Bearer (opaque tokens, JWT verified via your own `validate`).
app.post(
  "/admin/purge",
  {
    operationId: "adminPurge",
    hooks: bearerAuth({
      validate: (token) => timingSafeEqual(token, process.env.ADMIN_TOKEN!),
      realm: "admin",
    }),
    responses: { 204: { description: "ok" }, 401: { description: "denied" } },
  },
  async () => ({ status: 204 as const, body: undefined }),
);

// Basic auth (RFC 7617).
app.use(basicAuth({
  realm: "books-api",
  verify: (user, pass) =>
    timingSafeEqual(user, "admin") &&
    timingSafeEqual(pass, process.env.ADMIN_PASSWORD ?? ""),
}));

SQL injection

Daloy doesn't ship a database driver, but the HTTP boundary it does own (strict Zod schemas, hardened JSON parser, body-size caps) shrinks the surface that reaches your repository layer. See SQL injection for the safe vs. unsafe patterns per ORM (Prisma, Drizzle, Kysely, raw drivers), an allowlisting recipe for dynamic ORDER BY, and the grep rules the maintainers use to catch regressions.

Command injection

DaloyJS's runtime is child_process-free by CI gate, so the framework itself cannot shell out. See Command injection for the safe shape of a handler that does need to invoke an external program (execFile + argv array, never exec(`cmd $${input}`)), the Windows BatBadBut footgun, and the grep rules to keep new bugs out at PR time.

Admin panels

Building an admin or customer-success surface on top of DaloyJS? See Secure admin panels for the recommended pattern: internal: true routes, ipRestriction(), strict CSP with per-request nonces, per-admin authentication, login-throttle rateLimit() groups, and structured audit logging, mapped one-to-one to Aikido's public "secure admin panel" checklist.

Supply-chain

DaloyJS is distributed via pnpm for a stricter install model. Scaffolded pnpm apps inherit the install-time controls, while DaloyJS's own repository and the optional GitHub Actions bundle add CI/CD controls against the cache-poisoning, maintainer-phishing, and OIDC token-abuse patterns seen in recent npm incidents.

  • Strict isolation: packages cannot reach phantom dependencies.
  • Content-addressable store: every byte is hashed and verified.
  • Frozen lockfile in CI with --ignore-scripts: reproducible installs without transitive lifecycle execution.
  • verify-store-integrity, corruption-detecting reads.
  • strict-peer-dependencies, no silent peer mismatches.
  • minimum-release-age=1440, wait 24h before installing fresh releases.
  • ignore-scripts=true with explicit pnpm.onlyBuiltDependencies: reviewed allowlist for native install scripts.
  • SHA-pinned GitHub Actions: the optional generated GitHub workflows pin third-party actions to immutable commits, not mutable tags.
  • Protected DaloyJS npm publishing: the framework's own packages use a tag-only release workflow, protected environment approval, OIDC trusted publishing, and --provenance.

If your generated app lives outside GitHub, carry over the portable parts directly and translate the GitHub workflow rules to your CI host. The framework cannot enforce branch protection or runner egress in a private GitLab, Bitbucket, Azure DevOps, or on-prem installation.

Trusted proxies and rate limiting

DaloyJS no longer trusts X-Forwarded-For or X-Real-IP by default when deriving a rate-limit key. Those headers are client-spoofable unless your reverse proxy strips and rewrites them. The default limiter is therefore global until you provide an explicit keyGenerator or opt in to trustProxyHeaders: true / trustedProxies behind a trusted proxy. When proxy headers are trusted, the key is the rightmost X-Forwarded-For entry (the one your proxy appended), never an attacker-prepended left entry; multi-hop chains declare their hop count with trustedHops. When the origin itself can be reached, set trustedProxies so the peer socket is verified against a CIDR allowlist before any forwarded header is believed (see the autoBan note).

For credential-entry routes, use loginThrottle() across /login, OTP, and password-reset routes, and wsRateLimit() on related WebSocket upgrades. Both helpers can spend from the same groupId bucket.

Self-hosted docs assets

The built-in docs helpers no longer force a jsDelivr-shaped CSP. You can self-host the Swagger UI or Scalar assets, add a nonce to the bootstrap script, and emit a same-origin CSP for your docs route.

ts
import {
  swaggerUiHtml,
  htmlResponse,
} from "@daloyjs/core/docs";

const nonce = crypto.randomUUID();
const html = swaggerUiHtml({
  specUrl: "/openapi.json",
  scriptNonce: nonce,
  assets: {
    swaggerUiCssUrl: "/docs-assets/swagger-ui.css",
    swaggerUiBundleUrl: "/docs-assets/swagger-ui.js",
  },
});

return htmlResponse(html, {
  assetOrigins: [],
  scriptNonce: nonce,
  allowInlineStyles: false,
});
ini
# .npmrc
ignore-scripts=true
minimum-release-age=1440
strict-peer-dependencies=true
prefer-frozen-lockfile=true
verify-store-integrity=true
provenance=true

For the full CI/CD and maintainer playbook, read Supply-chain security. Run pnpm audit --prod in CI and before release.

OWASP API Security Top 10 mapping

For a per-item walkthrough of how Daloy addresses every entry in the OWASP API Security Top 10 (2023) plus the cross-cutting best practices (encryption, validation, rate limiting, logging, inventory, third-party API safety), read OWASP API Top 10 mapping.

Reporting a vulnerability

Use GitHub's private vulnerability reporting at github.com/daloyjs/daloy/security/advisories/new with reproduction steps. Do not open a public issue with exploit details.