JWT and authentication safeguards
The authentication slice resolves signing keys bykid, runs theverifyhook on every request, and addsCache-Control: no-storeto authentication failures.
DaloyJS is a Relying Party, not an auth server
DaloyJS validates tokens. It does not mint user sessions through an authorization-code flow, host a consent screen, or store user/client credentials. Pair these middleware with a dedicated identity provider:
- Hosted IdPs: Auth0, Okta, Azure AD / Entra ID, AWS Cognito, Google Identity, Clerk, WorkOS, Supabase Auth, Logto, Stytch, Kinde. Anything that publishes a standard
/.well-known/jwks.jsonworks withjwk()out of the box. - Self-hosted IdPs: Keycloak, Ory Hydra, ZITADEL, Authentik, Dex. Same JWKS contract, same one-line
jwk()setup. - Your own sibling auth service: a separate DaloyJS app using
createJwtSigner()to mint tokens and exposing a JWKS endpoint. The API service then validates those tokens withjwk()exactly as it would for an external IdP.
Rough mapping of which middleware to reach for:
- Browser app + external IdP (OIDC):
jwk()on the API,requireScopes()per route,session()only if you also need server-side session state alongside the access token. - Service-to-service inside one tenant:
bearerAuth({ validate })with an opaque token, orjwk()if both sides already speak JWT. The internal-service preset relaxes browser-only headers for these endpoints. - Webhook receivers: neither
bearerAuth()norjwk(). Use the dedicated HMAC verifier (see the security overview). - Admin tools / scripts:
basicAuth()behindipRestriction(), or short-lived JWTs from your IdP.
Daloy provides a cohesive set of authentication safeguards. Each one is additive and opt-in:
jwk(): asymmetric-only Bearer-token middleware backed by a JWKS source. RefusesHS*at construction, requires akidheader that matches a JWK in the set, and cross-checks JWT-headeralgagainst the JWK's declaredalgwhen both are present.bearerAuth({ verify })/jwk({ verify }): per-request revalidation hook so revocation lists, token-version counters, and "user changed password since this token was issued" checks can invalidate previously-issued credentials.basicAuth({ onAuthSuccess }): typed-context callback that fires afterctx.state.useris stamped (the objectverifyreturned, or{ username }), so handlers do not re-parse theAuthorizationheader.Cache-Control: no-storeon every first-party auth helper401challenge (bearerAuth(),basicAuth(),jwk()) so intermediaries never cache an auth challenge, RFC 9111 §3.5 and audit alignment.
1. jwk() middleware
Drop-in Bearer-token middleware backed by a JWKS source. The algorithm allowlist is intentionally narrow: only RS256 / RS384 / RS512, PS256 / PS384 / PS512, ES256 / ES384 / ES512, and EdDSA. Symmetric HS* algorithms are refused at construction, the classic confused-deputy "HS256 verified with the JWKS public key as the HMAC secret" attack cannot be configured. The middleware is exported from the dedicated subpath @daloyjs/core/jwk and from the package root.
- 01requestClientjwk() middlewareRequest with Bearer tokenJWT header carries kid + alg
- 02asyncjwk() middlewareIdP JWKSFetch key set by kidTTL-cached, in-flight dedup, stale fallback
- 03notejwk() middlewarejwk() middlewareCross-check JWT alg vs JWK alg. Reject HS*asymmetric-only allowlist
- 04responsejwk() middlewareClientVerify signature + exp, stamp ctx.state.user{ sub, scopes, claims }
jwks accepts a static JwkSet, an https:// URL (with TTL caching and in-flight-promise dedup so a thundering-herd of concurrent requests resolves into a single fetch), or a custom resolver function. http:// JWKS URLs and non-finite / negative fetchTtlSeconds / maxStaleSeconds are refused at construction. The middleware stamps ctx.state.user = { sub, scopes, claims }. The scope normalizer reads the first non-empty claim of scope (RFC 6749 space-separated string), scp (Azure AD array), and scopes (array), in that order, and dedupes the result.
maxLifetimeSeconds is an optional positive integer. The example requires exp and rejects tokens whose lifetime exceeds five minutes with 401 invalid_token. The check is exp - (iat ?? now) <= maxLifetimeSeconds, using the verification time when iat is absent. It does not renew a token or rotate its signing key. Zero, negative, fractional, and non-finite values are refused at construction. When omitted, there is no lifetime cap and exp is validated only when present.
When the JWKS source is a URL, a TTL-expiry refresh that fails (network error, non-2xx, or malformed body) does not take down every request: the last successfully fetched key set keeps serving for a bounded maxStaleSeconds grace window (default 3600, set 0 to disable) on top of fetchTtlSeconds. The first fetch is never eligible for this fallback, so an unreachable IdP at boot still fails closed, and tokens are always cryptographically verified and exp-checked when present. Removing a key at the provider does not immediately invalidate cached keys. Setting maxStaleSeconds: 0 disables stale fallback, not the normal cache TTL; use a per-request revocation check when offboarding must take effect immediately.
Every JWKS source (object, URL, or resolver function) is read per verification, after the token header parses, so a malformed token never triggers a JWKS fetch. Each key is imported into WebCrypto once for the life of the middleware, cached by its full JWK content rather than its kid. A resolver that rotates key material under the same kid takes effect on the next request, and a resolver that returns different key sets per request (for example per tenant) always verifies each token against the set returned for that request.
Service-to-service policy
For machine callers, pin both issuer and audience, set a short maxLifetimeSeconds, and apply requireScopes() to protected routes. The issuer check is opt-in. From 1.5.4, jwk() and createJwtVerifier() refuse to construct in production (NODE_ENV=production, or createJwtVerifier({ env: "production" })) without an audience, and an App resolving to production refuses to register such a jwk(), unless you pass allowAnyAudience: true (meant for a single-tenant private identity provider that mints tokens only for this service); outside production a missing audience is still accepted. Use distinct identities and secret stores for each workload and environment; env: "production" does not isolate credentials. The verify hook below can check whether the verified service identity is still active in your own store. DaloyJS does not inventory service accounts, rotate credentials, detect credential reuse, or automate offboarding. Use an identity provider and secret manager for those lifecycle controls.
2. Per-scheme verify(credentials, ctx) hook
Both bearerAuth() and jwk() accept an optional verify callback that runs after the static validate / signature check passes. Returning false throws ForbiddenError (403, no WWW-Authenticate per RFC 6750); returning true or undefined accepts. Use it to consult a revocation list, a token-version counter, or any other per-request signal that a previously-issued token has been invalidated. These callbacks run in preBody: raw route/header context is available, but ctx.body is always undefined so an unauthenticated upload can be rejected before it is consumed.
- 01staticvalidate / signature checktoken shape or JWT signature + exp
- 02revalidateverify(credentials, ctx)revocation list, token-version, password-changed
- 03returns falseForbiddenError403, no WWW-Authenticate (RFC 6750)
- 04true / undefinedRequest acceptedhandler runs
3. basicAuth({ onAuthSuccess })
Fires once ctx.state.user has been stamped (the object verify returned, or { username }), with the typed (credentials, ctx) tuple. The previous idiomatic workaround was a separate beforeHandle that re-parsed the Authorization header in every handler. That is no longer necessary. The callback runs in preBody, so move any logic that requires a validated request body into a later beforeHandle.
4. Cache-Control: no-store on auth 401 challenges
Every first-party auth helper now emits Cache-Control: no-store alongside WWW-Authenticate on the 401 response. A shared CDN, a corporate proxy, or a service-worker cache could previously cache the challenge and serve it to a different user; no-store closes that fingerprinting and stale-challenge risk. This applies uniformly to bearerAuth(), basicAuth(), and the new jwk().
Related auth safeguards
Related protections include the wsRateLimit() adapter, loginThrottle() preset, rotateSession() helper, the file-upload MIME + magic-byte + size guard, the requirePayloadAuth scheme flag, and the WebSocket-helper safe defaults, are covered in WebSocket and login safeguards.