Composition & network
Think of it like… the velvet ropes outside a club. They steer traffic into the right line (allow/deny lists viaipRestriction), share a single headcount across a group of doors (rateLimit({ groupId })), and let staff slip in through the side door without queueing (internal: trueroutes reachable only viaapp.inject()).
Daloy ships the composition & network slice of the secure-by-default initiative: four primitives that compose the security stack you already have. Every item is opt-in; no existing behaviour changes unless you call the new helper.
1. rateLimit({ groupId }) shared buckets
Every rateLimit() call that declares the same groupId shares one in-memory bucket. Use it to enforce a combined limit across related routes (e.g. login, OTP, password reset) without juggling a shared store yourself.
When you supply a custom store, Daloy still prefixes the derived key with `${groupId}:` so two groups cannot collide in a shared Redis backend either.
2. combine primitives, every / some / except
Declarative composition for your Hooks bundles. Use them to package curated security stacks as a single value and drop the fragile if (...) await next() chains.
every(...layers)runs every bundle in order across every lifecycle phase. Forwards CORS / CSRF / session security markers so boot-time guards still see them on the composed bundle.some(...layers)tries each layer'sbeforeHandleuntil one passes. A returnedResponseis treated as a denial, the next layer gets a turn. The first failure wins when every layer rejects, so place the auth scheme whoseWWW-Authenticatechallenge you want clients to see first.except(when, hooks)skips the wrappedbeforeHandlefor matching paths (/health,/public/**,/v1/*/meta) or for any request where the supplied predicate returnstrue. OnlybeforeHandleis gated so shared concerns like request-id propagation keep running.
Perimeter guards run on unmatched requests too.beforeHandleguards registered withapp.use(...), such asrateLimit(),ipRestriction(),csrf(), and your own WAF bundles, also run on the cold dispatch path: a request that matches no route (404), hits a registered path with the wrong method and gets405, or arrives as anOPTIONSpreflight. Without this, an attacker could flood random paths to slip past arateLimit()that an operator reasonably expects to cover every request. Two consequences worth knowing:
- A flood of
404s is now throttled / IP-fenced just like traffic to a real route. - When auth is installed globally (e.g.
app.use(except(exempt, bearerAuth(...)))), an unauthenticated request to an unmatched path is rejected with401rather than404. This is intentional route-enumeration resistance: the perimeter answers the same way whether or not the route exists. Per-route hooks are unaffected, and no handler is ever reached. Need public unknown paths to stay plain404s instead? Avoid globalapp.use()auth for that surface; use route/group-scoped hooks for auth and reserveApp({ hooks })onRequestfor checks that should truly run before routing.
3. ipRestriction(): CIDR allow / deny
Block or allow requests by source IP or CIDR range. Pairs naturally with trustProxyHeaders: true behind a trusted proxy so the matched address is the real client, not your load balancer. Supports IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:a.b.c.d). deny always wins over allow.
Invalid IP literals, invalid CIDR prefixes, and calls with neither an allow nor deny list throw at construction time, catching bugs that would otherwise hide until production traffic hits. By default the helper fails closed because Web-standard requests do not expose the peer address. Supply resolveIp if your adapter exposes connection metadata or if you sit behind a CDN that sends the real client through a custom header such as cf-connecting-ip or true-client-ip.
4. internal: true + app.inject()
Mark a route as internal: true and the public app.fetch(...) entry point returns 404: existence cannot be probed. The same route runs normally through app.inject(request), which is meant for cron jobs, admin scripts, and integration tests. Internal routes are also excluded from generated OpenAPI by default; pass includeInternal: true to generateOpenAPI()for private admin SDK generation. The framework also filtersAllow headers so a probe with a different method stays a clean 404 rather than a leaky 405.
Opt-out
Every primitive in this slice is additive; nothing changes unless you call the helper. The earlier secure-defaults master opt-out flag still applies if you ever need to disable secure defaults in a development sandbox: