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-in | What 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, CSRF | Cookie sessions for a backend-for-frontend. |
| mTLS, HTTP message signatures | Service 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 throughrequireRoles(). - Auth0: universal login, MFA, social login.
jwk()against your tenant; RBAC permissions throughrequireRoles(). - Okta: workforce identity with custom authorization servers.
jwk()against the authorization server; groups throughrequireRoles(). - AWS Cognito: user pools with hosted sign-in. The official
aws-jwt-verifySDK throughbearerAuth(), orjwk(). - Keycloak: the most widely used self-hosted, open-source IdP.
jwk()against the realm; realm and client roles throughrequireRoles(). - Clerk: embeddable auth with users, organizations, and billing.
@clerk/backendauthenticateRequest(). - LoginRadius: customer identity and social login.
loginradius-sdkon Node-style runtimes. - Better Auth: self-hosted email/password, OAuth, and sessions. Mount its handler and guard routes with
auth.api.getSession().
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:
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:
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:
| Provider | Where roles live | Guard |
|---|---|---|
| Entra ID | roles (app roles) | requireRoles(["Books.Admin"]) |
| Auth0 (RBAC) | permissions | requireRoles(["delete:books"], { claim: "permissions" }) |
| Auth0 (Actions) | namespaced, e.g. https://acme.example/roles | requireRoles(["admin"], { claim: "https://acme.example/roles" }) |
| Okta | groups (once you add a groups claim) | requireRoles(["Engineering"], { claim: "groups" }) |
| Cognito | cognito:groups | requireRoles(["admins"], { claim: "cognito:groups" }) |
| Keycloak | realm_access.roles (nested) | requireRoles(["admin"], { claim: (c) => (c.realm_access as { roles?: unknown } | undefined)?.roles }) |
| Zitadel | urn: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
claimnames 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 beforeidempotency()orresponseCache()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
403does 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
roleswith noscp, so guard machine routes withrequireRoles()rather thanrequireScopes(). - Auth0 marks these tokens with
gty: "client-credentials"and puts granted scopes inscope. - Pin the caller. Allowlist the client ID (
azporclient_id, depending on the IdP) injwk({ 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:
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:
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:
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
/introspectendpoint frombearerAuth({ 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
| Approach | Node-based hosts1 | Deno | Cloudflare Workers, workerd | Fastly Compute |
|---|---|---|---|---|
Built-in jwk() | Yes | Yes | Yes | Yes2 |
AWS Cognito (aws-jwt-verify) | Yes | Yes | Yes (Web Crypto) | Not verified |
Clerk (@clerk/backend) | Yes | Yes | Yes | Not verified |
Okta SDK (@okta/jwt-verifier) | Yes | Not verified | No (Node-only) | No |
LoginRadius (loginradius-sdk) | Yes | Not verified | No | No |
Better Auth (better-auth) | Yes | Yes | Depends on database adapter | Not verified |
- Node.js, Bun, Vercel Functions, Netlify Functions, AWS Lambda, Azure Functions, Google Cloud Run.
- Fastly blocks undeclared outbound requests: declare your IdP's host as a backend in
fastly.tomlsojwk()can fetch the JWKS.
Security checklist
- Always verify the signature. Never trust an unverified JWT. Decode-only utilities are for debugging.
- Pin
issandaud. A correct signature on the wrong audience is still a token confusion attack.jwk()refuses to start in production without anaudienceunless you opt out withallowAnyAudience: true(and then checkclient_idyourself, 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+HttpOnlyvia the built-in session middleware.