Skip to content

Search docs

Jump between documentation pages.

Browse docs

Auth-cohesive slice

Think of it like…a building lobby that does three things most don't. It checks not just your badge but who issued it (JWKS lookup by kid), revalidates that you still work here on every request (the verifyhook), and refuses to cache the "denied" answer at the elevator, so a fired employee can't ride up tomorrow on a stale 401 (Cache-Control: no-store).

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.json works with jwk() 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 with jwk() 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, or jwk() if both sides already speak JWT. The internal-service preset relaxes browser-only headers for these endpoints.
  • Webhook receivers: neither bearerAuth() nor jwk(); use the dedicated HMAC verifier (see the security overview).
  • Admin tools / scripts: basicAuth() behind ipRestriction(), or short-lived JWTs from your IdP.

Daloy closes the auth-cohesive subset of the leftover items from the secure-by-default initiative. Each one is additive and opt-in:

  • jwk(): asymmetric-only Bearer-token middleware backed by a JWKS source. Refuses HS* at construction, requires a kid header that matches a JWK in the set, and cross-checks JWT-header algagainst the JWK's declared alg when 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 after ctx.state.user.username is stamped, so handlers do not re-parse the Authorization header.
  • Cache-Control: no-store on every first-party auth helper 401 challenge (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.

Verifying a Bearer token with jwk()
Clientjwk() middlewareIdP JWKS
  1. 01requestClientjwk() middlewareRequest with Bearer tokenJWT header carries kid + alg
  2. 02asyncjwk() middlewareIdP JWKSFetch key set by kidTTL-cached, in-flight dedup, stale fallback
  3. 03notejwk() middlewarejwk() middlewareCross-check JWT alg vs JWK alg; reject HS*asymmetric-only allowlist
  4. 04responsejwk() middlewareClientVerify signature + exp, stamp ctx.state.user{ sub, scopes, claims }
jwk() resolves the signing key by kid from the JWKS source, cross-checks the JWT-header alg against the JWK, and refuses HS* so the confused-deputy attack cannot be configured. Tokens are always cryptographically verified and exp-checked.
ts
import { App } from "@daloyjs/core";
import { jwk } from "@daloyjs/core/jwk";

const app = new App();

app.use(
  jwk({
    algorithms: ["RS256", "ES256"],
    jwks: "https://login.example.com/.well-known/jwks.json",
    issuer: "https://login.example.com/",
    audience: "https://api.example.com",
    fetchTtlSeconds: 600,
    maxStaleSeconds: 3600,
    realm: "api",
  }),
);

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 scope (RFC 6749 space-separated string), scp (Azure AD array), and scopes (array) claims and dedupes the result.

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 very 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 regardless.

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.

Static check then per-request revalidation
  1. 01staticvalidate / signature checktoken shape or JWT signature + exp
  2. 02revalidateverify(credentials, ctx)revocation list, token-version, password-changed
  3. 03returns falseForbiddenError403, no WWW-Authenticate (RFC 6750)
  4. 04true / undefinedRequest acceptedhandler runs
The verify hook runs only after the static validate or signature check passes, so a structurally valid but revoked token is still rejected with 403. Returning true or undefined accepts the request.
ts
import { bearerAuth } from "@daloyjs/core";

app.use(
  bearerAuth({
    validate: (token) => verifyOpaqueToken(token),
    verify: async (token, ctx) => {
      const tenantId = ctx.request.headers.get("x-tenant-id") ?? "default";
      return !(await isTokenRevoked(tenantId, token));
    },
  }),
);

3. basicAuth({ onAuthSuccess })

Fires once ctx.state.user.username has been stamped, 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.

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

app.use(
  basicAuth({
    verify: (username, password) => verifyCredentials(username, password),
    onAuthSuccess: async ({ username }, ctx) => {
      ctx.state.authenticatedUser = username;
      await recordBasicAuthSuccess(username);
    },
  }),
);

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

What shipped next

The remaining leftover items, 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, shipped in the 0.23.0 remaining slice.