Protect routes
authMiddleware returns a Warlock middleware. It has typed/default hard-gate forms plus an object form for one credential source, optional resolution, and a page-local redirect. Legacy authMiddleware(userType, "cookie:name") remains supported.
Use { source: "header" } (the default) or { source: "cookie", key: "access_token" } to select one credential source.
Common forms
Section titled “Common forms”Middleware belongs in the route options.middleware array.
router.get("/account", accountController, { middleware: [authMiddleware()], // configured default user type});
router.get("/admin", adminController, { middleware: [authMiddleware("admin")],});
router.get("/feed", feedController, { middleware: [authMiddleware({ source: "cookie", key: "access_token", optional: true })],});
router.get("/profile", profileController, { middleware: [authMiddleware("user", { source: "cookie", key: "access_token", redirect: { to: "/login", returnUrlParam: "return_to" }, })],});authMiddleware() uses auth.defaultUserType; with several configured user
classes and no default, it throws during setup instead of guessing. optional: true permits anonymous continuation only. A presented credential is still
fully verified and hydrates request.locals.user when valid. The legacy
authMiddleware("user", "cookie:access_token") form remains valid.
Automatic cookie renewal
Section titled “Automatic cookie renewal”Opt in on a route with one allowed user type and both cookie descriptors:
router.get("/account", accountController, { middleware: [authMiddleware("user", { source: "cookie", key: "access_token", refresh: { source: "cookie", key: "refresh_token", overlapMs: 5000 }, redirect: { to: "/login", returnUrlParam: "return_to" }, })],});Missing or expired access credentials can be renewed once before the handler
runs. Both replacement cookies are written using the configured cookie policy;
the handler and mutations are never replayed. Run the additive authMigrations
before enabling renewal.
Concurrent requests presenting the same refresh token may receive its exact
immediate active successor during a bounded duplicate window: five seconds by
default, clamped to zero through ten seconds. A later rotated successor is never
substituted. Outside that window, old-token reuse revokes the family. The public
authService.refreshTokens() API retains strict replay handling. Family logout
invalidates the persisted credentials even if an older response later sets a
cookie; arbitrary out-of-order cookie delivery across generations is not solved
by this mechanism.
The local redirect applies only to page-route authentication failures. API
requests retain status responses; authenticated disallowed user types get 403.
What the middleware does on success
Section titled “What the middleware does on success”Before your controller runs:
request.locals.user = <hydrated user model instance>;request.decodedAccessToken = <decoded JWT payload>;The user is loaded via Model.find(decoded.id) against the model class registered under the token’s userType. If the user no longer exists (deleted account), the access-token row is destroyed and the request 401s.
What it does on failure
Section titled “What it does on failure”| Error code | When |
|---|---|
MissingAccessToken (EC001) | No credential at the configured source (401) |
InvalidAccessToken (EC002) | Token doesn’t verify — signature, expired, doesn’t match the DB row, user gone |
Unauthorized (EC003) | Token valid but user-type isn’t in the allowed list (403) |
The response shape (via response.unauthorized):
{ "error": "...localized message...", "errorCode": "EC001" }The error code is from the AuthErrorCodes enum — handy for the frontend to switch on without parsing the message.
Reading the user in a controller
Section titled “Reading the user in a controller”import type { RequestHandler } from "@warlock.js/core";
const accountController: RequestHandler = async ({ request, response }) => { const user = request.locals.user!;
return response.success({ id: user.id, email: user.get("email"), });};In a hard-gated controller, request.locals.user is guaranteed. With optional: true, check it before using it.
Route-group protection
Section titled “Route-group protection”router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () => { router.get("/users", listUsersController); router.post("/users", createUserController); router.delete("/users/:id", deleteUserController);});Every route inside the group is gated — the group’s middleware array applies to each route in the callback. Cleaner than repeating the middleware per route.
Cookie-sourced credentials
Section titled “Cookie-sourced credentials”New in 5.12. Pass `cookie:${name}` as the second argument to read the credential from a cookie instead of the Authorization header:
router.get("/account", accountController, { middleware: [authMiddleware({ source: "cookie", key: "access_token" })],});Nothing writes that cookie for you implicitly — pair it with authService.setAuthCookie / clearAuthCookie in your login/logout controllers (see Handle login and logout). Upgrading never starts a bearer-only app emitting Set-Cookie on its own.
Automatic cookie renewal
Section titled “Automatic cookie renewal”The 5.19 refresh descriptor is opt-in and is being reconciled with the final
durable coordinator. Its release contract is narrow: only an exact immediately
active successor pair may be reused during the configured duplicate window
(default five seconds, range zero through ten). The strict legacy
authService.refreshTokens path remains unchanged. A tolerated duplicate is a
security tradeoff, not general replay acceptance; later old-token reuse revokes
the family, and family logout invalidates late cookies. It does not solve
arbitrary out-of-order HTTP cookie delivery.
CSRF Origin check for cookie auth
Section titled “CSRF Origin check for cookie auth”New in 5.12. A cookie-sourced credential can be silently replayed cross-site by a browser (a same-site GET redirect chain, or a client that ignores SameSite) in a way a header token cannot — nothing but your own JS can attach an Authorization header, but a browser attaches cookies automatically. To close that gap, authMiddleware automatically runs a CSRF Origin check whenever both are true:
- the credential came from a
cookie:source (not"header"), and - the request method is unsafe (
POST/PUT/PATCH/DELETE).
GET / HEAD / OPTIONS and header-token auth are never checked — only the cookie + unsafe-method combination is in scope.
The check passes when Origin — or, if Origin is absent, Referer — names the request’s own origin (scheme + host) or an entry in auth.csrf.allowedOrigins (default [], configured in src/config/auth.ts). A request that carries neither header is rejected too — the check fails closed, not open.
A failure is a 403 with AuthErrorCodes.CsrfOriginMismatch (EC006):
{ "error": "...localized message...", "errorCode": "EC006" }import type { AuthConfigurations } from "@warlock.js/auth";
const authConfig: AuthConfigurations = { // ... csrf: { allowedOrigins: ["https://admin.example.com"], },};
export default authConfig;This closes the residual CSRF gap that SameSite=Lax alone leaves open for cookie auth. It’s not a full double-submit-token mechanism — that’s deferred to a later release — but it stops the common cross-site cookie-replay case outright, and it costs nothing on header-token routes.
Default CSRF-Origin guard (applies with or without authMiddleware)
Section titled “Default CSRF-Origin guard (applies with or without authMiddleware)”New in 5.17. The check above only ever ran inside authMiddleware("cookie:*") — a cookie-authenticated write reaching any other path (an app-owned optional-auth pattern that reads its own token cookie directly, without ever calling authMiddleware) was never checked at all.
@warlock.js/core now runs the same Origin/Referer check at the earliest HTTP seam, before route middleware and before any app handler, for every request where:
- the method is
POST/PUT/PATCH/DELETE, and - the request carries a
Cookieheader naming anything other than the framework’s ownlocalecookie (aCookieheader that fails to parse cleanly counts as carrying one — it fails closed), and - the route hasn’t opted out (below).
It reuses the exact same same-origin / auth.csrf.allowedOrigins comparison and the same 403 / AuthErrorCodes.CsrfOriginMismatch (EC006) response as the authMiddleware check above, so the two never disagree. A header-only API request (no Cookie header at all, e.g. Authorization: Bearer …) is completely unaffected, and a route already covered by authMiddleware("cookie:*") is not checked twice or logged twice — the earlier guard short-circuits the request before that middleware runs.
Exempting a route — { csrf: false }
Section titled “Exempting a route — { csrf: false }”⚠️ Dangerous. Only exempt a route that cannot present a same-origin Origin/Referer by construction and is safe without this check — a third-party callback the browser is redirected to directly, or a machine-to-machine route no browser ever calls with cookies. Never exempt a route an ordinary signed-in browser session writes to.
router.post("/oauth/callback", oauthCallbackController, { csrf: false });No optional / fallthrough auth
Section titled “No optional / fallthrough auth”There is no “hydrate request.locals.user if a token is present, otherwise continue” mode. authMiddleware always requires a valid token. Public routes leave the middleware off entirely; protected groups apply it once:
// Public — no middlewarerouter.get("/feed", feedController);
// Protectedrouter.get("/account", accountController, { middleware: [authMiddleware([])] });router.get("/admin", adminController, { middleware: [authMiddleware("admin")] });If a public route needs soft personalization, read request.authorizationValue yourself in the controller (see Reading the user in a controller).
Custom error responses
Section titled “Custom error responses”The middleware uses response.unauthorized({...}). To remap globally — say you want a code field in addition to errorCode, or a 403 on Unauthorized instead of 401 — hook the framework’s error transformer to react to the AuthErrorCodes.* values. See HTTP response.
Reading the decoded payload
Section titled “Reading the decoded payload”import type { RequestHandler } from "@warlock.js/core";
const adminController: RequestHandler = async ({ request, response }) => { const decoded = request.decodedAccessToken; // decoded.id, decoded.userType, decoded.created_at, plus any custom claims you signed.};The decoded payload is exactly what was passed to jwt.generate. The default shape is { id, userType, created_at }; if you used a custom payload on createTokenPair / generateAccessToken, everything you signed is here.
Things to avoid
Section titled “Things to avoid”- Calling
authMiddlewareinside the handler. It returns a middleware function — call it once per route at registration. Calling it per-request creates a freshallowedTypesarray each hit. - Manually decoding JWTs. The middleware did it; the result is on
request.decodedAccessToken. - Trusting client-set
request.locals.user. The middleware is the only thing that writes that slot server-side. Client headers don’t reach it. - Passing an unknown user-type.
authMiddleware("typo")401s every request because the config lookup fails. Smoke-test the wire-up with a real token of each user type.
Related
Section titled “Related”- The auth flow — where the middleware sits in the lifecycle.
- Customize user type — the
config.auth.userTyperegistry. - Handle login and logout — where the access token comes from.