Skip to content

Search docs

Jump between documentation pages.

Browse docs

DaloyJS auth docs

DaloyJS does not bundle a user database or login UI. A hosted or self-hosted identity provider (IdP) owns accounts and issues tokens. Your API receives a bearer token, verifies it, and gates routes by scope, role, or organization. DaloyJS ships the verifying and gating parts, so for most providers you install nothing extra.

New to this? Start with Auth architecture: where DaloyJS fits in OAuth2 & OpenID Connect. It explains why DaloyJS is a resource server (it verifies tokens, it does not issue them), how that compares to .NET and Duende IdentityServer, whether you actually need Auth0/Okta/Clerk or can self-host an open-source IdP, and the two architectures we recommend.

What DaloyJS ships

These are framework features, not recipes. They run before the request body is read, and every one fails closed.

Built-inWhat it does
jwk()Verifies JWT access tokens against your IdP's JWKS URL. Asymmetric algorithm allowlist, required kid, iss / aud / exp / nbf checks, key caching and rotation, no redirects followed. No dependencies, every runtime.
requireScopes()Requires OAuth scopes. Reads scope, scp (Entra, Okta), or scopes. 401 without a caller, 403 without the scope.
requireRoles()Requires roles, groups, or permissions from any claim: roles, groups, cognito:groups, permissions, or a nested claim through a reader function.
bearerAuth()Extracts the bearer token safely and hands it to your verifier (a provider SDK, an introspection call). Return a user object and the scope and role guards work on it.
basicAuth()HTTP Basic for internal tools, with constant-time comparison guidance.
createJwtVerifier()The lower-level verifier behind jwk(), for custom flows.
Sessions, CSRFCookie sessions for a backend-for-frontend.
mTLS, HTTP message signaturesService and webhook identity without bearer tokens.

Supported providers

  • Microsoft Entra ID: enterprise SSO for Microsoft 365 and Azure. jwk() against the tenant JWKS; app roles through requireRoles().
  • Auth0: universal login, MFA, social login. jwk() against your tenant; RBAC permissions through requireRoles().
  • Okta: workforce identity with custom authorization servers. jwk() against the authorization server; groups through requireRoles().
  • AWS Cognito: user pools with hosted sign-in. The official aws-jwt-verify SDK through bearerAuth(), or jwk().
  • Keycloak: the most widely used self-hosted, open-source IdP. jwk() against the realm; realm and client roles through requireRoles().
  • Clerk: embeddable auth with users, organizations, and billing. @clerk/backend authenticateRequest().
  • LoginRadius: customer identity and social login. loginradius-sdk on Node-style runtimes.
  • Better Auth: self-hosted email/password, OAuth, and sessions. Mount its handler and guard routes with auth.api.getSession().
One resource server, many identity providers
your codeDaloyJS API (resource server)jwk() / bearerAuth() verify · requireScopes() / requireRoles() gate
Entra ID · Auth0 · Oktajwk() + JWKS URL
Keycloak · Zitadel · any OIDCjwk() + JWKS URL
AWS Cognitoaws-jwt-verify or jwk()
Clerk · LoginRadiusprovider SDK
Better Authhandler + getSession
Any provider that issues JWT access tokens with a JWKS endpoint plugs into the built-in jwk(). Providers with their own SDK or opaque tokens plug into bearerAuth(). Either way, the same scope and role guards gate your routes.

Pattern A: any OIDC provider with jwk()

If your provider issues JWT access tokens and publishes a JWKS URL (Entra ID, Auth0, Okta custom authorization servers, Keycloak, Zitadel, Authentik, Ory, Cognito), this is all you need:

src/app.tsts
import { App, every, jwk, requireRoles, requireScopes } from "@daloyjs/core";

const issuer = process.env.OIDC_ISSUER!; // exactly as the token's `iss`

export const app = new App();

// 1. Authenticate every route: 401 on a missing, expired, or forged token.
app.use(
  jwk({
    jwks: process.env.OIDC_JWKS_URL!,   // from the issuer's /.well-known/openid-configuration
    algorithms: ["RS256"],              // asymmetric only; match what your IdP signs with
    issuer,
    audience: process.env.OIDC_AUDIENCE!, // your API's identifier
    maxLifetimeSeconds: 86_400,         // also makes `exp` mandatory
  }),
);

// 2. Authorize per route: 403 when the caller lacks the scope or role.
app.get("/books", {
  hooks: requireScopes(["books:read"], { phase: "preBody" }),
  responses: { 200: { description: "OK" } },
}, () => ({ status: 200, body: [] }));

app.delete("/books/:id", {
  hooks: every(requireScopes(["books:write"], { phase: "preBody" }), requireRoles(["admin"])),
  responses: { 204: { description: "Deleted" } },
}, () => ({ status: 204, body: undefined }));

jwk() puts the verified token on ctx.state.user as { sub, scopes, claims }, so a handler reads ctx.state.user.sub or any claim without parsing the token again. Use the verify option for checks beyond the standard claims, such as an allowlist of tenant IDs (tid) or client IDs (azp).

To protect only part of the API, put jwk() on the routes (or a registered plugin) instead of app.use(), or exclude public paths with except().

Pattern B: a provider SDK or opaque tokens with bearerAuth()

Some providers want their own SDK (Cognito's aws-jwt-verify, LoginRadius), and some tokens are opaque strings you have to check with the IdP. bearerAuth() extracts the token (refusing malformed and duplicated Authorization headers) and calls your validate. Return a user object, and requireScopes() and requireRoles() work exactly as in pattern A:

src/auth.tsts
import { App, bearerAuth, requireRoles } from "@daloyjs/core";
import { CognitoJwtVerifier } from "aws-jwt-verify";

const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID!,
  tokenUse: "access",
  clientId: process.env.COGNITO_CLIENT_ID!,
});

export const app = new App();

app.use(
  bearerAuth({
    realm: "books-api",
    validate: async (token) => {
      try {
        const claims = await verifier.verify(token);
        // Same shape jwk() produces, so the built-in guards can read it.
        return { sub: claims.sub, scopes: claims.scope?.split(" ") ?? [], claims };
      } catch {
        return false; // 403
      }
    },
  }),
);

app.get("/admin/stats", {
  hooks: requireRoles(["admins"], { claim: "cognito:groups" }),
  responses: { 200: { description: "OK" } },
}, () => ({ status: 200, body: {} }));

A validate that returns false answers 403; a missing or malformed header answers 401 with a WWW-Authenticate challenge before validate runs.

Roles, groups, and permissions

OAuth scopes say what a client may do. Many IdPs put what a user may do in a separate claim. requireRoles() reads it:

ProviderWhere roles liveGuard
Entra IDroles (app roles)requireRoles(["Books.Admin"])
Auth0 (RBAC)permissionsrequireRoles(["delete:books"], { claim: "permissions" })
Auth0 (Actions)namespaced, e.g. https://acme.example/rolesrequireRoles(["admin"], { claim: "https://acme.example/roles" })
Oktagroups (once you add a groups claim)requireRoles(["Engineering"], { claim: "groups" })
Cognitocognito:groupsrequireRoles(["admins"], { claim: "cognito:groups" })
Keycloakrealm_access.roles (nested)requireRoles(["admin"], { claim: (c) => (c.realm_access as { roles?: unknown } | undefined)?.roles })
Zitadelurn:zitadel:iam:org:project:roles (object keyed by role)requireRoles(["admin"], { claim: (c) => Object.keys((c["urn:zitadel:iam:org:project:roles"] as object | undefined) ?? {}) })
  • A string claim names one top-level claim exactly, so namespaced names with dots and colons work. Its value can be an array or a space-delimited string.
  • match: "any" accepts the caller when it holds at least one listed role. The default, "all", requires every one.
  • It always runs in preBody: an unauthorized caller is refused before the body is read and before idempotency() or responseCache() could replay a stored response to it. Register your auth hook first.
  • It fails closed. A missing claim, a non-list value, or a reader that throws all mean "no roles", and the 403 does not say which role was missing.

Roles decide what kind of action a caller may take. Whether they may touch this record is still your code's job: apply resource authorization to every user- or tenant-owned row.

Service-to-service calls

A backend calling your API with no user present uses the OAuth client credentials flow. The token is still a JWT from the same issuer, so pattern A verifies it unchanged. What differs is the authorization:

  • Entra ID puts granted application roles in roles with no scp, so guard machine routes with requireRoles() rather than requireScopes().
  • Auth0 marks these tokens with gty: "client-credentials" and puts granted scopes in scope.
  • Pin the caller. Allowlist the client ID (azp or client_id, depending on the IdP) in jwk({ verify }) so a token issued to another client of the same tenant cannot call internal routes.
  • For callers you control end to end, mTLS or HTTP message signatures avoid bearer tokens entirely.

OpenAPI and the docs UI

Declare the scheme once and mark protected routes with auth. The generated OpenAPI document then lists the requirement per operation, and Scalar and Swagger UI show an Authorize button:

ts
const app = new App({
  openapi: {
    info: { title: "Books API", version: "1.0.0" },
    securitySchemes: { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } },
  },
});

app.get("/books", {
  auth: { scheme: "bearer", scopes: ["books:read"] },
  hooks: requireScopes(["books:read"], { phase: "preBody" }),
  responses: { 200: { description: "OK" } },
}, () => ({ status: 200, body: [] }));

auth is a declaration, not enforcement: the hooks do the checking. In production, DaloyJS refuses to boot when a route declares auth but no authentication hook runs on it, so the spec cannot claim protection the code does not have. For an OAuth2 or OpenID Connect scheme with login flows in the docs UI, see OpenAPI docs.

Testing protected routes

Do not call your real IdP from tests. Generate a key pair, sign tokens with createJwtSigner(), and hand jwk() the public key as an in-memory key set:

tests/auth.test.tsts
import { test } from "node:test";
import assert from "node:assert/strict";
import { App, createJwtSigner, jwk, requireRoles } from "@daloyjs/core";

const pair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"],
);
const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
const signer = createJwtSigner({
  alg: "ES256",
  key: await crypto.subtle.exportKey("jwk", pair.privateKey),
  maxLifetimeSeconds: 3600,
  header: { kid: "test" },
});

function tokenFor(claims: Record<string, unknown>) {
  const now = Math.floor(Date.now() / 1000);
  return signer.sign({ sub: "u1", iss: "https://issuer.test", aud: "books", iat: now, exp: now + 300, ...claims });
}

test("only admins can delete", async () => {
  const app = new App();
  app.use(jwk({
    jwks: { keys: [{ ...publicJwk, kid: "test", alg: "ES256", use: "sig" }] },
    algorithms: ["ES256"],
    issuer: "https://issuer.test",
    audience: "books",
  }));
  app.delete("/books/:id", {
    hooks: requireRoles(["admin"]),
    responses: { 204: { description: "Deleted" } },
  }, () => ({ status: 204, body: undefined }));

  const as = async (roles: string[]) =>
    app.request("/books/1", {
      method: "DELETE",
      headers: { authorization: `Bearer ${await tokenFor({ roles })}` },
    });
  assert.equal((await as(["admin"])).status, 204);
  assert.equal((await as(["reader"])).status, 403);
  assert.equal((await app.request("/books/1", { method: "DELETE" })).status, 401);
});

Build the app from a function that takes its auth config, so tests pass the test key set and production passes the real JWKS URL. Never ship a test key or a test issuer in production config.

Pinning the clock

If your tests freeze time (an injected now() for the rest of the app, fake timers, a fixed Date), do not mix wall-clock tokens with a frozen app clock. Both the signer and jwk() accept the same now function, in Unix seconds, that createJwtVerifier() already takes:

ts
let nowSeconds = 1_700_000_000; // the suite's frozen clock

const signer = createJwtSigner({
  alg: "ES256",
  key: privateJwk,
  maxLifetimeSeconds: 3600,
  header: { kid: "test" },
  now: () => nowSeconds, // default `iat` when the payload omits one
});

app.use(jwk({
  jwks: { keys: [publicJwk] },
  algorithms: ["ES256"],
  issuer: "https://issuer.test",
  audience: "books",
  now: () => nowSeconds, // exp / nbf / iat are checked against this clock
}));

const token = await signer.sign({ sub: "u1", exp: nowSeconds + 300 });
assert.equal((await call(token)).status, 200);

nowSeconds += 301; // step past exp without minting a new token
assert.equal((await call(token)).status, 401);

A clock that returns a non-finite value fails closed: sign() throws JwtError("invalid_clock") and verification rejects the token with 401. Leave now unset in production; it exists so tests can be deterministic, not so an app can "fix" clock drift. Use clockSkewSeconds for that.

Not supported (yet)

  • Opaque tokens and introspection (RFC 7662). There is no built-in introspection client. Call your IdP's /introspect endpoint from bearerAuth({ validate }) with your client credentials, and cache positive results briefly, because every request otherwise costs a network round-trip. Okta's org authorization server and Auth0 tokens without an audience are opaque.
  • DPoP (RFC 9449) and certificate-bound tokens (RFC 8705). DaloyJS does not verify proof-of-possession, so a stolen bearer token works for whoever holds it. Keep token lifetimes short, require TLS everywhere, and use mTLS for high-value service-to-service calls.
  • Issuing tokens. Login, refresh tokens, and consent belong to your IdP. See Auth architecture.

Runtime compatibility

ApproachNode-based hosts1DenoCloudflare Workers, workerdFastly Compute
Built-in jwk()YesYesYesYes2
AWS Cognito (aws-jwt-verify)YesYesYes (Web Crypto)Not verified
Clerk (@clerk/backend)YesYesYesNot verified
Okta SDK (@okta/jwt-verifier)YesNot verifiedNo (Node-only)No
LoginRadius (loginradius-sdk)YesNot verifiedNoNo
Better Auth (better-auth)YesYesDepends on database adapterNot verified
  1. Node.js, Bun, Vercel Functions, Netlify Functions, AWS Lambda, Azure Functions, Google Cloud Run.
  2. Fastly blocks undeclared outbound requests: declare your IdP's host as a backend in fastly.toml so jwk() can fetch the JWKS.

Security checklist

  • Always verify the signature. Never trust an unverified JWT. Decode-only utilities are for debugging.
  • Pin iss and aud. A correct signature on the wrong audience is still a token confusion attack. jwk() refuses to start in production without an audience unless you opt out with allowAnyAudience: true (and then check client_id yourself, as on Cognito).
  • Authorize after you authenticate. A valid token only proves who the caller is. Enforce scopes or roles for every privileged action, then apply resource authorization to every user- or tenant-owned record.
  • Use TLS everywhere. Bearer tokens are plaintext-equivalent. Require HTTPS and set secureHeaders (Strict-Transport-Security).
  • Rate-limit login redirects, token exchanges, and introspection passthroughs with rateLimit (or the Redis store) so abuse can't drive cost or lock out users.
  • If you also use session cookies (for an admin panel, say), enable CSRF and use SameSite=Lax + Secure + HttpOnly via the built-in session middleware.
Edit this page on GitHub

Last updated