API reference: Middleware & helpers
Built-in middleware, the every/some/except composition primitives, typed dependencies, config validation, structured logging, the startup banner, and connection-info helpers. Everything on this page is exported from the root @daloyjs/core barrel. See the API reference overview for the full module map.
Built-in middleware
RateLimitOptions
A limiter placed before preBody auth also counts requests that auth rejects by throwing, so failed credential guesses consume the budget.
By default the limiter sends X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Set standardHeaders: "draft-8" to also send the IETF RateLimit and RateLimit-Policy headers. Add legacyHeaders: false to send only the IETF pair. The option names and the policy name match express-rate-limit, so clients written against an Express API keep working after a move to Daloy.
t is the number of seconds until the window resets. No partition key (pk) is sent, so the bucket key never leaves the server. Turning headers off never turns enforcement off. Browsers can only read these headers cross-origin when they are listed in cors({ exposedHeaders }).
- windowMsrequired
- number
- Default:-
- Fixed-window width in milliseconds (e.g.
60_000for one minute). - maxrequired
- number
- Default:-
- Maximum allowed requests per
windowMsper key. - keyGenerator
- (ctx: RateLimitContext) => string
- Default:-
- Derive the bucket key from
ctx. Default: the trusted forwarded client IP when proxy trust is configured, otherwise the unspoofable TCP peer (peer:<ip>), and one shared"global"bucket only when the runtime exposes no peer. Never key on a raw client-supplied header. When the limiter precedespreBodyauthentication, it also receives failed attempts before body I/O; rely only on the raw request and state populated by earlierpreBodyhooks.When none ofkeyGenerator,trustProxyHeaders,trustedHops, ortrustedProxiesis set, the default key follows the App'sbehindProxyposture (viaresolveClientIp), so behind a proxy each client gets its own bucket instead of sharing the proxy's.behindProxymust describe your real topology: prefer{ cidrs }when you know the proxy addresses, because{ hops }trusts the right-most forwarded entries from anyone who can reach the app directly. - ipv6Subnet
- number
- Default:-
- IPv6 prefix length the default IP-based key groups on (
1-128). Default64: one subscriber normally holds a whole /64, so keying on the full address would give an attacker a fresh bucket per address. IPv4 and IPv4-mapped addresses are always keyed on the canonical dotted address. Ignored whenkeyGeneratoris set.Since 1.3.7 - store
- RateLimitStore
- Default:-
- Custom backend (default: shared in-memory store).
- trustProxyHeaders
- boolean
- Default:-
- Trust x-forwarded-for / x-real-ip when deriving the default key. Off by default because those headers are client-spoofable unless your reverse proxy strips and rewrites them.When enabled, the key is the **rightmost**
X-Forwarded-Forentry — the one your immediate proxy appended — never the attacker-influenceable leftmost one, so rotating spoofed left entries cannot evade the limit. Behind more than one proxy hop, settrustedHopsinstead; to also verify the peer is one of YOUR proxies (required when the origin itself can be reached), settrustedProxies. - trustedHops
- number
- Default:-
- Declare exactly how many proxy hops sit between Daloy and the public internet. Implies proxy-header trust and derives the default key that many entries from the right of
X-Forwarded-ForviaresolveForwardedClientIp. Must be an integer in [1, 64]; validated at construction. Ignored when a customkeyGeneratoris supplied. - trustedProxies
- readonly string[]
- Default:-
- Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate peer's address. Forwarded headers feed the bucket key only when the TCP socket actually talking to the adapter matches the list — the one property a remote client cannot spoof — so a direct-to-origin attacker can neither evade the limit with rotating spoofed XFF nor burn another identity's bucket. Implies proxy-header trust at one hop unless
trustedHopssays otherwise; validated and compiled at construction. On peer-less edge platforms verification fails closed. Ignored when a customkeyGeneratoris supplied. - retryAfter
- boolean
- Default:-
- When true, set Retry-After header on 429. Default: true.
- standardHeaders
- "draft-8"
- Default:-
- Also emit the IETF
RateLimit/RateLimit-Policyheaders (draft-ietf-httpapi-ratelimit-headers-08), e.g.RateLimit-Policy: "100-in-60sec"; q=100; w=60andRateLimit: "100-in-60sec"; r=42; t=17, wheretis seconds until the window resets. The policy name matches express-rate-limit's, so clients written against it keep working. Off by default. No partition key (pk) is sent, so the bucket key never leaves the server.Since 1.9.0 - legacyHeaders
- boolean
- Default:-
- Emit the
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Resetheaders. Default:true. Setfalsetogether withstandardHeadersto send only the IETF headers.Since 1.9.0 - groupId
- string
- Default:-
- Share a single bucket across every
rateLimit()call that declares the samegroupId. Use this to enforce one combined limit across related routes (e.g./login,/login/otp, and/password-resetall spend from the same per-IP "auth" bucket). WithoutgroupId, eachrateLimit()call gets its own independent in-memory store.Cooperation is only meaningful for the default in-memory store; when an explicitstoreis supplied the developer is responsible for keying it. The framework prepends thegroupIdto the derived key so two groups never collide in a shared store either.Since 0.19.0
src/middleware.ts.ETagOptions
- weak
- boolean
- Default:-
- Emit a weak ETag (
W/"..."). Defaultfalse— strong ETags allow range-request resumption and prevent byte-level cache poisoning by intermediaries. - generator
- (body: Uint8Array) => string | Promise<string>
- Default:-
- Override the digest function. Default: SHA-1 via WebCrypto. SHA-1 is appropriate here because the ETag is a cache identifier, not a security boundary; collisions only cause a stale
304, never a confidentiality break. - maxBytes
- number
- Default:-
- Largest body, in bytes, that is buffered to compute a tag. Bodies whose
Content-Lengthexceeds this are sent untagged instead of buffered. Default1_048_576(1 MiB). Must be a non-negative finite number.Since 1.3.7
src/etag.ts.requireScopes() options
requireScopes() reads ctx.state.user.scopes, which jwk(), bearerAuth() and other auth helpers fill in. jwk() builds that list from the token's scope claim (space-delimited string), or, when scope is absent, from scp (a space-delimited string as Microsoft Entra ID issues it since 1.10.0, or an array) or scopes (array). A caller without credentials gets 401 with a WWW-Authenticate: Bearer scope="..." challenge; a caller with too few scopes gets 403.
By default the check runs in beforeHandle, after the request body is read and validated. That means a valid token with too few scopes and a malformed body gets 400, and the server parses a body from a caller it is about to refuse. Since 1.10.0 you can authorize in preBody instead, right after header-only authentication:
phase: "preBody"runs the same check before the body is read: missing credentials are401, insufficient scope is403, whatever the body contains. The authentication hook must run first (earlier inevery(), or registered earlier withapp.use()), or every request is a401.match: "any"accepts a caller that owns at least one listed scope. It requiresphase: "preBody", and construction throws otherwise. Stored-response replays (idempotency(),responseCache()) run inbeforeHandleand only understand all-of requirements, so an any-of check must reject before they can serve a stored response. All-of guards keep feeding those replay checks; any-of guards do not.- The default (
phase: "beforeHandle",match: "all") is unchanged, so existing apps that authenticate inbeforeHandlekeep working.
- phase
- "beforeHandle" | "preBody"
- Default:"beforeHandle"
- Lifecycle phase the scope check runs in.
"beforeHandle"runs after the request body is read and validated, so a caller with too few scopes and a malformed body gets400."preBody"runs before any body I/O, right afterpreBodyauthentication such asjwk()/bearerAuth(), so an under-scoped caller always gets403and its body is never parsed. The authentication hook must run first (registered earlier, or earlier insideevery()); otherwise every request is a401. - match
- "all" | "any"
- Default:"all"
- How the listed scopes combine.
"all"requires every listed scope;"any"accepts a caller that owns at least one."any"requiresphase: "preBody": stored-response replays (idempotency(),responseCache()) only understand all-of requirements, so an any-of check must reject before anybeforeHandlereplay can run. Any-of guards are not added to the replay scope requirements.
src/middleware.ts.requireRoles() options
requireRoles() authorizes by role, group, or permission instead of OAuth scope. It reads a list-valued claim from ctx.state.user.claims (what jwk() stamps), or from ctx.state.user itself when an auth helper stamped a plain object (basicAuth(), or bearerAuth() whose validate returned one). No caller is a 401 with WWW-Authenticate: Bearer; a caller without the roles is a 403 that does not name them.
- A string
claimis one top-level claim name, read verbatim, so names containing dots or colons work.__proto__,constructor, andprototypeare refused at construction, and only the claims object's own properties are read. - The claim value may be an array of strings or a space-delimited string. Anything else, a missing claim, or a reader that throws counts as no roles (fail closed).
- The check always runs in
preBody, before the body is read and beforeidempotency()orresponseCache()could replay a stored response, so there is nophaseoption. Register the authentication hook first.
- claim
- string | RoleClaimReader
- Default:"roles"
- Where the granted roles live. A string names one top-level claim, read verbatim (so namespaced names such as
"https://example.com/roles"or"cognito:groups"work); its value may be an array of strings or a space-delimited string. Pass aRoleClaimReaderfor nested or object-shaped claims, e.g. Keycloak'srealm_access.roles. - match
- "all" | "any"
- Default:"all"
"all"requires every listed role;"any"accepts the caller when it holds at least one of them.
src/middleware.ts.BearerAuthOptions
Since 1.12.0, validate may return a user-shaped object instead of true. It is stamped on ctx.state.user, so requireScopes() (reading scopes) and requireRoles() work with any verifier: a provider SDK, an introspection call, or a test stub. Return { sub, scopes, claims } to match jwk(). See pattern B on the auth overview.
bearerAuth() and jwk() pass any token value through verbatim, with no length, charset or prefix check, so new token formats keep working. The one exception is a token containing whitespace or a comma. Duplicate Authorization headers are comma-joined into one value (Bearer a, Bearer b), so such a token is a 401 and never reaches validate() or the JWT verifier.
- validaterequired
- (token: string) => boolean | object | Promise<boolean | object>
- Default:-
- Token check (signature, format, or a provider SDK's verify call). Return a falsy value to reject the request with
403, ortrueto accept it.Since 1.12.0 it may instead resolve to a user-shaped object, which is stamped onctx.state.user, sorequireScopes()(readingscopes) andrequireRoles()(readingclaims, or the object itself) work with any verifier, not onlyjwk(). Return{ sub, scopes, claims }to match the shapejwk()produces. - realm
- string
- Default:-
- WWW-Authenticate realm. Default:
"api". - verify
- BearerAuthVerifyHook<string>
- Default:-
- Optional per-request revalidation hook. Called after
validateaccepts the token. Returningfalserejects the request with403; returningtrueorundefinedaccepts. Use for revocation lists, token-version counters, etc.Since 0.22.0
src/middleware.ts.Composition primitives
Dependencies (typed DI chain)
Configuration
Logging
Startup banner
Connection info & proxy posture
Subdomains (Public-Suffix-aware)
Next up: security & auth helpers.