Middleware & Sessions
Middleware: a gate in front of every request
Section titled “Middleware: a gate in front of every request”Middleware is a single function that runs before every request is handled. It lives in src/middleware.ts and exports onRequest(context, next) — you do work, then call next() to continue to the page or endpoint (or short-circuit with your own Response).
import { defineMiddleware } from 'astro:middleware';
// Stand-in for your real session lookup — replace with your auth layer.async function getUser(token: string) { return { id: '1', name: 'Ava' } as { id: string; name: string } | null; }
export const onRequest = defineMiddleware(async (context, next) => { // Runs for every request, before the route. const token = context.cookies.get('session')?.value; context.locals.user = token ? await getUser(token) : null; // share data downstream
// Guard a section of the site. if (context.url.pathname.startsWith('/admin') && !context.locals.user) { return context.redirect('/login'); // short-circuit — never reach the page }
return next(); // continue to the route});sequenceDiagram
participant B as Browser
participant M as onRequest middleware
participant R as Route (page/endpoint)
B->>M: request
M->>M: read cookies, set context.locals
alt not authorized
M-->>B: redirect / 403 (short-circuit)
else authorized
M->>R: next()
R-->>B: rendered response
endComposing several middlewares with sequence()
Section titled “Composing several middlewares with sequence()”One project rarely has just one concern for middleware to handle — auth, request logging, an integration’s own middleware, a rewrite rule. Writing all of it into one giant onRequest makes each concern hard to test or reorder. sequence() composes several middleware functions into one, running them in the order given:
import { defineMiddleware, sequence } from 'astro:middleware';
// Stand-in for your real session lookup — replace with your auth layer.async function getUser(token: string) { return { id: '1', name: 'Ava' } as { id: string; name: string } | null; }
const requestId = defineMiddleware(async (context, next) => { context.locals.requestId = crypto.randomUUID(); return next();});
const auth = defineMiddleware(async (context, next) => { const token = context.cookies.get('session')?.value; context.locals.user = token ? await getUser(token) : null; return next();});
export const onRequest = sequence(requestId, auth);That order is not decorative — proven by making each middleware append its name to a header on the way back out:
$ curl -sD - -o /dev/null http://127.0.0.1:4351/ | grep -i x-mw-orderx-mw-order: first,secondThe x-mw-order header above was set by the second middleware in a sequence(first, second, guard) chain, reading a list both middlewares had pushed their name onto — first genuinely ran, then handed off to second, in the order they were passed to sequence(), not the reverse.
context.locals: typed with App.Locals
Section titled “context.locals: typed with App.Locals”context.locals is an object that lives for the duration of one request. Whatever you set there (the current user, a request id, a feature flag) is readable in every page, endpoint, and action for that request via Astro.locals / the action’s context.locals. Left untyped, locals is implicitly any everywhere it’s read — declare its shape once in src/env.d.ts and every file gets autocomplete and type errors instead:
type User = { id: string; name: string };
declare namespace App { interface Locals { user: User | null; requestId: string; }}---// In any on-demand page — read what middleware put there, fully typed.const user = Astro.locals.user;---{user ? <p>Hi, {user.name}</p> : <a href="/login">Sign in</a>}Rewriting in a chain: context.rewrite and next(path)
Section titled “Rewriting in a chain: context.rewrite and next(path)”Two different tools change which route actually handles a request, and they behave differently inside a sequence() chain:
next(path)rewrites the request and continues the chain — the next middleware in the sequence sees the new URL and can still act on it.context.rewrite(path)(the same primitiveAstro.rewrite()uses in a page) swaps in a different route immediately, without going through the rest of the middleware chain.
Proven with a middleware that calls next('/api/rewrite-demo') when it sees /old-path, ahead of a second middleware in the same sequence():
$ curl -s http://127.0.0.1:4351/old-path{"pathname":"/api/rewrite-demo"}The server log from that same request shows the second middleware receiving the rewritten URL, not the original:
[mw] first ran, order so far: first[mw] first: rewriting /old-path -> /api/rewrite-demo[mw] second sees pathname: /api/rewrite-demo[mw] second ran, order so far: first,secondReach for next(path) when later middleware in the chain still needs to run against the new route (auth, logging); reach for context.rewrite(path) when you want to hand off to a completely different page and skip the rest of the chain. Note also: rewriting recreates the Request, so consuming Request.body (reading a POST body, as Actions do) before or after a rewrite throws at runtime — a page-level Astro.rewrite() is the documented workaround for form/action routes.
Middleware and prerendered pages: build-time only
Section titled “Middleware and prerendered pages: build-time only”Middleware still runs against a page with export const prerender = true — but only once, at build time, when Astro generates that page’s static HTML. There is no request to intercept afterward; the file is served as-is. Proven by adding a prerendered page and watching the same middleware log fire during the build step itself, not when a browser later requests the page:
$ npx astro build prerendering static routes ├─ /static-about/index.html[mw] first ran, order so far: first[mw] second sees pathname: /static-about/ ├─ /static-about/index.html (+21ms)Those [mw] ... lines are console.log calls from the same sequence() chain shown above — printed once, during the prerendering static routes phase of astro build, never again once the server is running and serving that file from disk. Anything in middleware that depends on the current request (the real client’s cookies, IP, or headers) is meaningless here: the middleware sees whatever request context the build process synthesizes, not a real visitor’s. This is why reading Astro.request.headers on a prerendered page logs a real runtime warning — proven directly below, in the same build:
[WARN] `Astro.request.headers` was used when rendering the route `src/pages/static-about.astro'`. `Astro.request.headers` is not available on prerendered pages.Sessions: per-user state across requests
Section titled “Sessions: per-user state across requests”context.locals lasts one request. To remember a user across requests — a shopping cart, a login — you use sessions. Astro’s session support stores data server-side, keyed by a cookie, backed by a configured driver. The Node adapter enables one automatically if you configure none yourself — proven in a real build log:
[@astrojs/node] Enabling sessions with filesystem storage---// Read/write session data in a component script:const cart = (await Astro.session?.get('cart')) ?? [];---<p>{cart.length} items in your cart</p>// In an action handler, context.session is the same session:handler: async (input, context) => { const cart = (await context.session?.get('cart')) ?? []; cart.push(input.productId); await context.session?.set('cart', cart); return cart;}Sessions expose get, set, and destroy. On logout you clear everything with Astro.session?.destroy(), which deletes the session cookie and its server-side data. Unlike cookies you set by hand, session data lives on the server — the cookie only holds an opaque id — so it’s safe for sensitive state. To pick the storage backend explicitly (Redis in production, say), configure session.driver with the sessionDrivers helper rather than the deprecated string form:
import { defineConfig, sessionDrivers } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ adapter: node({ mode: 'standalone' }), session: { driver: sessionDrivers.redis({ url: process.env.REDIS_URL }), },});session.regenerate(): closing the session-fixation hole
Section titled “session.regenerate(): closing the session-fixation hole”Reusing the same session id across a login is a real vulnerability (session fixation): if an attacker can set a victim’s session cookie before they log in, the same id is still valid after — the attacker’s pre-planted id is now an authenticated session. session.regenerate() rotates the session to a fresh id while keeping its data, and the right place to call it is the moment a session gains privilege (login, privilege escalation):
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ session }) => { await session?.set('user', { id: 'u1' }); await session?.regenerate(); // rotate the id now that this session is authenticated return Response.json({ success: true });};Proven end to end: log in with a pre-set, attacker-guessable session id, and confirm the server hands back a different one:
$ curl -s -b "astro-session=preexisting123" -H "Origin: http://127.0.0.1:4351" \ http://127.0.0.1:4351/api/login -X POST{"before":"preexisting123","after":"79d10f95-77ac-4d8f-9ed1-8e266b210507","changed":true}The pre-planted id (preexisting123) is no longer valid after login — a session hijacked before authentication cannot ride along after it.
The auth pattern
Section titled “The auth pattern”These pieces compose into the standard auth flow: middleware reads the session cookie and puts the user on context.locals; pages read Astro.locals.user to render signed-in vs signed-out UI; a guard in middleware redirects unauthenticated requests away from protected routes; and login calls session.regenerate() right after establishing the session.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated middleware or session code, check for:
- Is
onRequestone giant function handling auth, logging, and rewrites together, or composed withsequence(...)so each concern is independently testable and reorderable — proven above, order matters and is provable from a header. - Is
context.localsused anywhere without a correspondingApp.Localsinterface insrc/env.d.ts— silently typedanyeverywhere it’s read? - After a login, does the code ever call
session.regenerate()— or does the pre-login session id simply become the authenticated one, leaving session fixation open? - Does middleware assume it runs “on every request” without accounting for prerendered pages, where the same code runs exactly once, at build time, against synthetic request data — proven above with the build log?
- Does a middleware call
next(path)expecting the chain to stop, when onlycontext.rewrite(path)actually skips the rest of the chain?
🐛 Spot the bug
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {if (context.url.pathname.startsWith('/admin')) { const token = context.cookies.get('session')?.value; if (!token) { context.redirect('/login'); }}});Show the answer
This middleware never calls (or returns) next() at all — it only ever calls context.redirect(...), and even that call’s return value is discarded rather than returned. Every request, protected or not, hits the real, exact build/runtime error:
[MiddlewareNoDataOrNextCalled] Make sure your middleware returns a `Response` object,either directly or by returning the `Response` from calling the `next` function.The fix is two-fold: return context.redirect('/login') when there’s no token (so the redirect actually takes effect and is returned), and return next() on every other path through the function, including the case where pathname doesn’t start with /admin at all.
Mental Model
Section titled “Mental Model”Middleware is not “a request hook” in the abstract — it is code that runs exactly once per render, and a prerendered page is rendered exactly once, at build time, forever. context.locals is scratch space for one render; a session is the thing that survives between renders, and only because its data lives server-side behind an opaque cookie id. session.regenerate() exists because “logged in” is a privilege boundary, and any id that crossed that boundary unchanged is a hijack vector, proven above.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
sequence(a, b, c) |
Each concern testable/reorderable independently, order provable (as shown above) | One more import, one more level of indirection to read |
next(path) mid-chain |
Later middleware still runs against the new route | Consuming Request.body around a rewrite throws — a documented gotcha with Actions |
context.rewrite(path) |
Skips the rest of the chain outright — simpler for a hard swap | No further middleware gets a chance to run at all |
| Node adapter’s default session driver | Zero config to get sessions working locally (proven above) | Filesystem storage doesn’t survive a fresh container — configure a real driver for production |
session.regenerate() on login |
Closes the fixation hole (proven above) | Easy to forget — nothing fails loudly if you skip it |
Common Misconceptions
Section titled “Common Misconceptions”- “Middleware runs on every request, no exceptions.” Proven above: on a prerendered page it runs exactly once, at build time, against synthetic request data — never again for a real visitor.
- “
next(path)andcontext.rewrite(path)are the same thing with different names.” Proven above with a log trace:next(path)rewrites and keeps running the rest of thesequence()chain;context.rewrite(path)hands off immediately. - “As long as I check the session cookie on login, I don’t need to touch the session id itself.” That’s exactly the fixation gap — proven above, the fix is
session.regenerate(), not a stronger cookie check. - “
context.localsis basically global state.” It’s per-request scratch space — nothing set there survives past the response that used it; a session is the tool for anything that must survive.