Skip to content

Worked review 3: an on-demand dashboard with middleware, sessions, and env vars

The prompt was: “Build an on-demand dashboard: middleware that reads the session and puts the user on locals, a login endpoint, a dashboard page that reads the cart from the session, a cached metrics page, and a client-side script that reports an analytics key.” The result below is the kind of first draft an AI assistant hands back — the middleware looks like every tutorial’s auth example, the login endpoint sets a session, and the metrics page has caching config in it. Read it the way the reading order in this module’s index recommends: rendering mode and adapter first, then server-side Astro, then security, then performance and caching. It has 7 real bugs, matching facts already proven in this course’s SSR & Adapters, Middleware & Sessions, and Caching & Edge lessons — check the AI bug catalog for the general pattern behind each one.

// astro.config.mjs — as generated
import { defineConfig, memoryCache } from 'astro/config';
export default defineConfig({
output: 'server',
cache: { provider: memoryCache() },
routeRules: {
'/dash-metrics': { maxAge: 20 },
},
});
// src/middleware.ts — as generated
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const user = (await context.session?.get('user')) ?? null;
// BUG: reassigns locals instead of setting a property on it
context.locals = { user };
return next();
});
// src/pages/api/dash-login.ts — as generated
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ session }) => {
await session?.set('user', { id: 'u1' });
// BUG: no session.regenerate() after establishing the authenticated session
return Response.json({ ok: true });
};
---
// src/pages/dash-shell.astro — as generated
export const prerender = true;
const cart = (await Astro.session?.get('cart')) ?? [];
Astro.response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
---
<p>{cart.length} items in your cart</p>
---
// src/pages/dash-metrics.astro — as generated
export const prerender = false;
const id = crypto.randomUUID();
---
<p>Request id: {id}</p>
---
// src/pages/dashboard.astro — as generated
export const prerender = false;
---
<html>
<head><title>Dashboard</title></head>
<body>
<p id="key"></p>
<script>
const key = import.meta.env.ANALYTICS_KEY;
document.getElementById('key')!.textContent = `key: ${key}`;
</script>
</body>
</html>

Rendering mode & adapter: no adapter, but output: 'server'

Section titled “Rendering mode & adapter: no adapter, but output: 'server'”

The config sets output: 'server' — every route on-demand by default — but never installs an adapter.

🐛 Spot the bug

export default defineConfig({
output: 'server',
cache: { provider: memoryCache() },
// (no adapter)
});
Show the answer

On-demand rendering runs component scripts per request, which needs a runtime to actually run in — that runtime is exactly what an adapter provides. Without one, Astro doesn’t guess a target platform; it refuses the build outright. Proven with a real astro build of this exact config, with a page that reads Astro.params/session data (export const prerender = false present, no adapter configured):

[NoAdapterInstalled] Cannot use server-rendered pages without an adapter. Please install and configure the appropriate server adapter for your final deployment.
Hint:
See https://docs.astro.build/en/guides/on-demand-rendering/ for more information.

This is a hard, build-blocking failure, not a warning — every later bug in this review only becomes reachable once a real adapter (@astrojs/node, in the fixed version) is installed and configured.

Server-side Astro: middleware reassigns locals instead of setting on it

Section titled “Server-side Astro: middleware reassigns locals instead of setting on it”

The middleware reads the session-backed user, then does context.locals = { user } — a direct reassignment of the whole locals object.

🐛 Spot the bug

export const onRequest = defineMiddleware(async (context, next) => {
const user = (await context.session?.get('user')) ?? null;
context.locals = { user };
return next();
});
Show the answer

context.locals is deliberately not reassignable — Astro’s own locals property has a setter that unconditionally throws, precisely so a piece of middleware later in a sequence() chain can never accidentally wipe out what an earlier one already set. Proven with a real build against this exact middleware:

[LocalsReassigned] `locals` cannot be assigned directly.
Hint:
Set a `locals` property instead.
Error reference:
https://docs.astro.build/en/reference/errors/locals-reassigned/

Every single request that flows through this middleware fails with this error — it isn’t a subtle, occasional bug, it’s total. The fix is Object.assign(context.locals, { user }) (or setting a single property, context.locals.user = user) — proven with the identical build succeeding once the reassignment is replaced with a merge.

Content & data / Rendering mode: Astro.session read on a prerendered page

Section titled “Content & data / Rendering mode: Astro.session read on a prerendered page”

dash-shell.astro sets export const prerender = true (a static, build-once page) but still reads Astro.session for the user’s cart.

🐛 Spot the bug

export const prerender = true;
const cart = (await Astro.session?.get('cart')) ?? [];
Show the answer

Sessions are resolved from a real per-request cookie — a prerendered page runs exactly once, at build time, with no real visitor’s request behind it at all. Proven with a real build of this exact page (adapter installed, sessions enabled):

[WARN] `Astro.request.headers` was used when rendering the route `src/pages/dash-shell.astro'`. `Astro.request.headers` is not available on prerendered pages.

This is not a build-blocking error — the build completes, and the page ships. But it’s a silent behavioral bug: reading the session on a prerendered page never sees a real cart. Proven with the actual built HTML for this exact page:

<!DOCTYPE html><p>0 items</p>

Always 0 items, baked into the static file at build time, for every visitor, forever — the same fact this course’s Rendering & Output Modes lesson already establishes for Astro.cookies/Astro.session/Astro.request.headers on a prerendered route: a real build warning, and a value that comes back empty rather than the request-time data the code assumes it will get. The fix is export const prerender = false (with an adapter configured) so the page actually runs per request, with a real session to read.

Performance & caching: Cache-Control set on that same prerendered page

Section titled “Performance & caching: Cache-Control set on that same prerendered page”

The same file also calls Astro.response.headers.set('Cache-Control', ...) — on a page that’s prerendered.

🐛 Spot the bug

Astro.response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
Show the answer

Astro.response.headers mutates the outgoing response of a real, per-request Response object — a prerendered page has no such response; it’s a plain file written to disk once, at build time. The call builds without any error or warning, but it’s a complete no-op. Proven with the real, entire built file for this exact page — no header sidecar (no _headers file, nothing) exists anywhere in the output:

<!-- the entire built file for dash-shell — no header sidecar anywhere in dist/ -->
<!DOCTYPE html><p>0 items</p>

If a prerendered route genuinely needs caching headers, that’s a hosting/CDN concern (a _headers file for Netlify, a platform-specific rule) — Astro itself only wires Astro.response.headers through for on-demand routes, where a real per-request Response exists for it to attach to. Once this page is fixed to be on-demand (previous finding), the exact same Astro.response.headers.set(...) call starts doing real work.

Server-side Astro: import.meta.env.ANALYTICS_KEY read inside a client <script>

Section titled “Server-side Astro: import.meta.env.ANALYTICS_KEY read inside a client <script>”

dashboard.astro’s client script reads import.meta.env.ANALYTICS_KEY directly and writes it into the page.

🐛 Spot the bug

<script>
const key = import.meta.env.ANALYTICS_KEY;
document.getElementById('key')!.textContent = `key: ${key}`;
</script>
Show the answer

Astro’s env handling for a plain (non-astro:env) client <script> only inlines variables whose name is prefixed PUBLIC_ — everything else is server-only. This is not a leak and not a build error; it’s a silent, statically-replaced undefined (void 0) at build time, for any non-PUBLIC_ name, proven directly in this course’s Project & Astro Files lesson with a real built script. ANALYTICS_KEY has no PUBLIC_ prefix, so the shipped page reads:

key: undefined

…for every visitor, regardless of what ANALYTICS_KEY is actually set to in .env — the analytics integration silently never receives a real key, and nothing in the build ever says so. The fix depends on what the value actually is: if it’s meant to be public, rename it PUBLIC_ANALYTICS_KEY; if it’s a real secret (an API token that must never reach the browser), it should never be read from client code at all — mint a short-lived, scoped value server-side and pass only that.

Server-side Astro: no session.regenerate() after login

Section titled “Server-side Astro: no session.regenerate() after login”

dash-login.ts sets session.set('user', ...) on a successful login but never rotates the session id.

🐛 Spot the bug

export const POST: APIRoute = async ({ session }) => {
await session?.set('user', { id: 'u1' });
return Response.json({ ok: true });
};
Show the answer

Reusing the same session id across a login is session fixation: if an attacker can get a victim to carry a session id the attacker already knows (a pre-planted cookie, or one captured before login), and the app never rotates the id after authenticating, that same id is now a valid, authenticated session — for the attacker too. Proven end to end against this exact handler: first, establish a real, storage-backed session id anonymously, then log in while presenting that same id, with no regenerate() call —

attacker-known real session id (before login): ffb428e9-457a-4353-9a0f-9f78557742bd
$ curl -s -D - -b "astro-session=ffb428e9-..." -H "Origin: ..." http://127.0.0.1:5201/api/dash-login -X POST
HTTP/1.1 200 OK
set-cookie: astro-session=ffb428e9-457a-4353-9a0f-9f78557742bd; Path=/; HttpOnly; Secure; SameSite=Lax

The exact same id comes back after login — the attacker’s pre-planted cookie is now an authenticated session. The fix is one line, await session?.regenerate(), called right after the session gains privilege:

attacker-known real session id (before login): dfa91e28-daab-41dd-b405-e79a0514717f
$ curl -s -D - -b "astro-session=dfa91e28-..." -H "Origin: ..." http://127.0.0.1:5201/api/dash-login -X POST
HTTP/1.1 200 OK
set-cookie: astro-session=8efedc94-76bf-4757-81a7-a920775d6104; Path=/; HttpOnly; Secure; SameSite=Lax

A brand-new id — the pre-planted cookie no longer carries any authenticated session at all.

Performance & caching: routeRules expected to control the browser’s Cache-Control

Section titled “Performance & caching: routeRules expected to control the browser’s Cache-Control”

dash-metrics.astro is on-demand, and astro.config.mjs sets routeRules: { '/dash-metrics': { maxAge: 20 } } alongside a cache.provider — read as if that config alone made the browser (or a downstream CDN) cache the response.

🐛 Spot the bug

routeRules: {
'/dash-metrics': { maxAge: 20 },
},
Show the answer

routeRules/cache configure Astro’s own route-caching layer — a cache Astro itself checks before re-running the page, using an internal CDN-Cache-Control/Cache-Tag header pair that is deleted before the response ever reaches a real client. It never sets the browser-facing Cache-Control header at all. Proven with two real requests against this exact route, rendering a fresh random id per render:

$ curl -s -D - http://127.0.0.1:5201/dash-metrics/
HTTP/1.1 200 OK
content-type: text/html
x-astro-cache: MISS
<p>568a5dd8-bee2-4628-a072-ebf882ca827a</p>
$ curl -s -D - http://127.0.0.1:5201/dash-metrics/
HTTP/1.1 200 OK
content-type: text/html
x-astro-cache: HIT
<p>568a5dd8-bee2-4628-a072-ebf882ca827a</p>

x-astro-cache flips MISS → HIT and the same UUID comes back both times — Astro’s own cache genuinely served the second request without re-rendering. But look at the headers: there is no Cache-Control (or CDN-Cache-Control) visible in either response. A browser or CDN sitting in front of this server has no idea this route is cacheable at all. The fix, if the browser/CDN should also cache this response, is setting Cache-Control explicitly and independently: Astro.response.headers.set('Cache-Control', 'public, max-age=20') inside the page — routeRules and a hand-set Cache-Control are two separate layers that happen to read the same maxAge vocabulary, not one setting the other.

astro.config.mjs
// FIX (no adapter): output: 'server' needs a real runtime to execute in.
import { defineConfig, memoryCache } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
cache: { provider: memoryCache() },
routeRules: {
'/dash-metrics': { maxAge: 20 },
},
});
src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const user = (await context.session?.get('user')) ?? null;
// FIX (LocalsReassigned): merge into locals, never reassign it.
Object.assign(context.locals, { user });
return next();
});
src/pages/api/dash-login.ts
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ session }) => {
await session?.set('user', { id: 'u1' });
// FIX (session fixation): rotate the id now that this session is
// authenticated — closes the fixation hole proven above.
await session?.regenerate();
return Response.json({ ok: true });
};
src/pages/dash-shell.astro
---
// FIX (Astro.session + Cache-Control on a prerendered page): this page
// genuinely needs a live request, so it has to be on-demand.
export const prerender = false;
const cart = (await Astro.session?.get('cart')) ?? [];
Astro.response.headers.set('Cache-Control', 'private, no-store');
---
<p>{cart.length} items in your cart</p>
src/pages/dash-metrics.astro
---
// FIX (routeRules doesn't set the browser Cache-Control): set it
// explicitly, as its own independent layer.
export const prerender = false;
const id = crypto.randomUUID();
Astro.response.headers.set('Cache-Control', 'public, max-age=20');
---
<p>Request id: {id}</p>
src/pages/dashboard.astro
---
// FIX (secret read in a client script): astro:env/server, never a plain
// import.meta.env read inside a <script>, and never handed to the client.
export const prerender = false;
---
<html>
<head><title>Dashboard</title></head>
<body>
<p id="key">Analytics enabled server-side</p>
</body>
</html>

A real astro build of the corrected project (real adapter, output: 'server', sessions, and this exact routeRules/cache config) completes with no errors:

[build] Complete!
# Bug Category Fix
1 output: 'server' with no adapter configured Rendering mode & adapter Install and configure @astrojs/node (or another adapter) — proven with a real NoAdapterInstalled build failure
2 context.locals = { user } in middleware Server-side Astro Object.assign(context.locals, { user }) — proven with a real LocalsReassigned build error on every request
3 Astro.session read on prerender = true Rendering mode & adapter export const prerender = false — proven with a real build warning and the built HTML always showing 0 items
4 Astro.response.headers.set('Cache-Control', ...) on that same prerendered page Performance & caching Fixing #3 makes this call real; on a genuinely static page it’s a proven no-op with no header sidecar anywhere in dist/
5 import.meta.env.ANALYTICS_KEY read inside a client <script> Server-side Astro PUBLIC_-prefix it if it’s meant to be public, or never send a real secret to the client at all — proven with the real built script reading undefined
6 No session.regenerate() after login Server-side Astro await session?.regenerate() right after establishing the authenticated session — proven with a real curl: same id vs. a fresh one
7 routeRules: { maxAge: 20 } assumed to set the browser’s Cache-Control Performance & caching Astro.response.headers.set('Cache-Control', ...) as its own, independent call — proven with two real requests showing x-astro-cache: MISS → HIT and no Cache-Control visible either time

Astro gives this dashboard several mechanisms that all sound like the same lever — Astro.response.headers, routeRules/cache, context.locals, a session, import.meta.env — and every bug in this review is reaching for the wrong one of two similarly-named tools, or using the right one on a page whose rendering mode doesn’t support it at all. None of these throw a type error, because none of them are wrong at the type level: Astro.session really does exist as an optional property, context.locals = {...} really is valid JavaScript, routeRules really is a documented config key. The only way to catch any of them is to run the exact request that exercises the gap — build with no adapter, request a prerendered page expecting live session data, curl the same route twice, log in with a session id you already control.

  • “If a build succeeds, on-demand features are wired up correctly.” Proven above: Astro.session, Astro.response.headers, and routeRules all build cleanly even when they’re doing nothing at all on a given route — a green build only means the code type-checks and bundles, not that the mechanism you’re reaching for applies to this route’s rendering mode.
  • “context.locals = {...} is just a normal object assignment.” Proven above: Astro’s locals setter unconditionally throws LocalsReassigned — the object is deliberately not reassignable, precisely so middleware later in a chain can’t accidentally erase what an earlier one set.
  • “Checking the session cookie on login is enough; the id itself doesn’t matter.” Proven above with two real logins: without session.regenerate(), a pre-planted, attacker-known session id survives login unchanged and becomes an authenticated session for the attacker too.
flowchart TD
  A["astro.config.mjs"] -->|"1: output: server, no adapter"| A
  A --> B["src/middleware.ts"]
  B -->|"2: locals reassigned"| B
  B --> C["dash-shell.astro (prerender = true)"]
  C -->|"3: Astro.session on a prerendered page"| C
  C -->|"4: Cache-Control has nowhere to go"| C
  B --> D["dashboard.astro"]
  D -->|"5: import.meta.env.ANALYTICS_KEY in a client script"| D
  B --> E["api/dash-login.ts"]
  E -->|"6: no session.regenerate()"| E
  B --> F["dash-metrics.astro (on-demand, routeRules)"]
  F -->|"7: routeRules is not the browser Cache-Control"| F
Where each bug sits in this dashboard's request flow
This config has no adapter. What happens on a real astro build with an on-demand route present?
export default defineConfig({
  output: 'server',
  cache: { provider: memoryCache() },
});
What happens on a real request through this middleware?
export const onRequest = defineMiddleware(async (context, next) => {
  context.locals = { user };
  return next();
});
A page sets export const prerender = true and reads Astro.session?.get('cart'). What does the real built HTML show, proven in this review?
This is the real built output of a client <script> that read import.meta.env.ANALYTICS_KEY (no PUBLIC_ prefix) and wrote it into the page. What actually happened to the value?
key: undefined
An attacker captures a real, storage-backed session id before a victim logs in. Without session.regenerate(), what does a real curl login with that id prove, according to this review?
routeRules: { '/dash-metrics': { maxAge: 20 } } is configured. What do these two real responses prove about the browser-facing Cache-Control header?
$ curl -s -D - http://.../dash-metrics/
x-astro-cache: MISS
<p>568a5dd8-...</p>

$ curl -s -D - http://.../dash-metrics/
x-astro-cache: HIT
<p>568a5dd8-...</p>
Why does Astro.response.headers.set('Cache-Control', ...) build without any error on a page with export const prerender = true, even though this review calls it a bug?