How to Add Authentication to an Astro Site
Set up Astro authentication with adapters, middleware, Astro.locals, sessions, rewrite, and Actions, including login, logout, and route protection.
Authentication in Astro depends as much on how a page is rendered as on which library you pick. By default, Astro prerenders the pages in your project at build time, and you opt individual routes out of that. A prerendered page is generated before any visitor exists, so there is no request, no cookie header and no session to read.
A first attempt often fails quietly for this reason. You install an auth library, paste its middleware into src/middleware.ts, load /account, and Astro.locals is an empty object. No error, no log, no stack trace to search for.
This article walks the whole chain in order, file by file: the adapter, the per-route prerender export, the middleware that resolves the user, the page that reads it, route protection with rewrite(), login and logout as Astro Actions, and the boundary where client-side islands stop seeing any of it. The code targets Astro 7.x. Astro 6 raised the Node floor to 22 and dropped support for Node 18 and 20, so run Node 22.12.0 or later.
Key Takeaways
- Astro prerenders the entire project by default, so any page that reads a session must opt out with
export const prerender = false, and on-demand rendering requires an adapter. - If your middleware appears to do nothing, the route it should protect is almost certainly still prerendered.
Astro.localslives for exactly one route render; data that must survive to the next request belongs inAstro.session, which requires a session driver.- Astro Actions are publicly reachable HTTP endpoints, so every handler needs its own
context.localscheck; protecting the page that renders the form does not protect the action behind it. Astro.localsandAstro.sessionare server-side only, so a component with aclient:*directive can only see what the page passed it as props.
Why Does Static by Default Break Astro Authentication?
An Astro project builds to static HTML unless a route asks for something else, and static HTML is produced once, at build time, for every visitor. Prerendering the whole project is the documented default, as the on-demand rendering guide sets out. A session lives in a request header that does not exist yet when that HTML is written to disk, so cookie reads, Astro.request, and anything a middleware puts on locals have nothing to attach to.
The practical consequence: middleware that never seems to run is a rendering-mode symptom, not an auth-library bug.
How Do You Enable On-Demand Rendering?
On-demand rendering needs two things: an adapter, which produces a server for your target runtime, and a per-route opt-out. Astro’s first-party adapters are @astrojs/cloudflare, @astrojs/netlify, @astrojs/node and @astrojs/vercel, with community adapters alongside them, and the adapter option is documented in the configuration reference. Install one with npx astro add node and check that adapter’s own page, since config options differ.
Then choose a shape. The output option takes exactly two values, 'static' and 'server'.
| Site shape | astro.config.mjs | Page frontmatter |
|---|---|---|
| Content site with a few logged-in pages | adapter installed, keep the default output: 'static' | export const prerender = false on every route that reads a session |
| Mostly logged-in app | adapter installed, set output: 'server' | export const prerender = true on marketing and content routes |
Whichever you pick, the rule is the same: the session-reading route must be on-demand rendered, and the Node adapter docs cover the runtime specifics for the example here.
The Middleware: src/middleware.ts
Middleware resolves the user once per request and hands it to everything downstream. Put the file at src/middleware.js|ts, or at src/middleware/index.js|ts if you prefer a folder, and give it a named export called onRequest. A default export will not be picked up, a rule the middleware guide is explicit about.
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const stored = (await context.session?.get('user')) ?? null;
context.locals.user = stored as User | null;
return next();
});
This reads Astro’s built-in session rather than parsing a cookie by hand. The same session turns up under two names, as Astro’s sessions guide describes: pages and components reach it through Astro.session, while middleware, API endpoints and action handlers get it from context.session. Storage is not automatic. Three adapters pick a default driver for you, Node, Cloudflare and Netlify; with any other adapter you name one yourself, following the session driver reference. One constraint worth knowing before you deploy to an edge runtime: sessions do not work in edge middleware.
Type locals by augmenting the App namespace in src/env.d.ts:
// src/env.d.ts
type User = {
id: string;
email: string;
};
declare namespace App {
interface Locals {
user: User | null;
}
}
Reading the User With Astro.locals
A page reads whatever the middleware assigned, as long as that page is rendered on demand. Astro.locals lives for exactly one route render, so it is the right place to carry a user object from middleware to a page and the wrong place to keep anything that must survive to the next request.
---
// src/pages/account.astro
/* On-demand rendering */ export const prerender = false;
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<h1>Signed in as {user.email}</h1>
Delete line one and the page goes back into the prerender set: it builds to static HTML, Astro.locals.user is never populated, and every visitor gets the same file. That single line is the difference between working auth and a login screen nobody can pass.
How Do You Protect Routes With context.rewrite()?
Protect routes by centralizing the gate in middleware and rendering the login page in place instead of redirecting to it. context.rewrite('/login') shows different content at the URL the visitor asked for, which keeps the protected path in the address bar.
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
const protectedPaths = ['/account', '/dashboard'];
export const onRequest = defineMiddleware(async (context, next) => {
const stored = (await context.session?.get('user')) ?? null;
context.locals.user = stored as User | null;
const needsAuth = protectedPaths.some((path) =>
context.url.pathname.startsWith(path),
);
if (needsAuth && !context.locals.user) {
return context.rewrite('/login');
}
return next();
});
Because the rewrite starts the render over, your middleware runs a second time on the way through, so keep /login out of protectedPaths or that second pass fails the same check and loops. Broken auth of this kind rarely throws: session replay of a login flow shows the user bouncing between the login page and the protected route, which is what a mis-scoped session cookie or a gate placed in the wrong branch actually looks like from the outside.
Avoid the other rewrite form here. When you hand next() a Request, Astro builds a replacement request out of the old one, and any attempt to read the body after that point (or before it) throws at runtime. That bites hardest when an Action is driven by an HTML form, which is why the docs steer you to context.rewrite() or Astro.rewrite() instead.
Login and Logout With Astro Actions
Astro Actions, added in astro@4.15, are the built-in way to handle login and logout, and they replace hand-rolled API routes. Define them in a server object exported from src/actions/index.ts, set accept: 'form', and post to them from plain HTML.
// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro/zod';
// Replace with your own credential lookup.
async function verifyCredentials(email: string, password: string): Promise<User | null> {
return null;
}
export const server = {
login: defineAction({
accept: 'form',
input: z.object({
email: z.email({ error: 'Enter a valid email address.' }),
password: z.string(),
}),
handler: async ({ email, password }, context) => {
const user = await verifyCredentials(email, password);
if (!user) throw new ActionError({ code: 'UNAUTHORIZED' });
await context.session?.regenerate();
await context.session?.set('user', user);
return { ok: true };
},
}),
logout: defineAction({
accept: 'form',
handler: async (_input, context) => {
await context.session?.destroy();
return { ok: true };
},
}),
deleteAccount: defineAction({
accept: 'form',
handler: async (_input, context) => {
if (!context.locals.user) throw new ActionError({ code: 'UNAUTHORIZED' });
return { ok: true };
},
}),
};
z comes from astro/zod, which re-exports Zod v4, so top-level validators like z.email() and the error key for custom messages are the current spelling. Regenerating the session ID on login guards against session fixation, and destroy() clears the cookie and drops the saved data on the server.
Note deleteAccount. Actions are publicly accessible endpoints with their own URLs, so anyone can call one directly without ever loading the page that renders its form. Every handler that touches user data checks context.locals itself.
The page side is a form and a result read:
---
// src/pages/login.astro
/* On-demand rendering */ export const prerender = false;
import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.login);
if (result && !result.error) return Astro.redirect('/account');
---
{result?.error && <p class="error">Those details did not match.</p>}
<form method="POST" action={actions.login}>
<input type="email" name="email" required />
<input type="password" name="password" required />
<button>Log in</button>
</form>
Logout is the same shape: <form method="POST" action={actions.logout}>, no client-side JavaScript required.
The Island Trap: Islands Never See locals
Astro.locals and Astro.session are server-side only. Middleware, .astro pages and layouts, API routes and action handlers all run on the server and share them. A component carrying a client:* directive hydrates in the browser, sits outside that chain, and sees only what the page passed it as props.
---
// Renders logged-out UI forever. The island cannot reach locals.
import UserMenu from '../components/UserMenu.jsx';
---
<UserMenu client:load />
---
// Correct: the page reads locals on the server and passes the value down.
import UserMenu from '../components/UserMenu.jsx';
const user = Astro.locals.user;
---
<UserMenu client:load user={user} />
Nothing throws in the broken version. The page renders logged-in content while the island beside it renders a sign-in button, a mismatch that only shows up visually.
This example uses Astro’s built-in sessions, and the same sequence holds for a library: Astro’s authentication guide points to auth libraries such as Better Auth and Clerk for email sign-in and OAuth, and Better Auth’s framework-agnostic design extends to Astro, as our BetterAuth overview explains. Whichever you pick, it still needs an adapter, a non-prerendered route, and middleware that writes to context.locals.
Wrapping Up
Auth in Astro is a chain with one weak link at the front: no adapter and no prerender = false means no request, and everything downstream quietly does nothing. Get the rendering mode right first, then middleware, then the per-handler checks on your actions. Open the on-demand rendering guide next to your astro.config.mjs, install an adapter, and add export const prerender = false to the first page that needs a user.
FAQs
Do Astro Actions work on a prerendered page?
No. A page must be rendered on demand to call an action through a form action, so add 'export const prerender = false' to the page that holds the form, and install an adapter so a server exists to run the handler. Action request bodies also have a default ceiling of 1 MB (1048576 bytes); raise security.actionBodySizeLimit if a handler has to accept something bigger, such as an upload.
Can a prerendered static page show logged-in or logged-out UI?
Not on the server. A prerendered page is written to disk at build time and every visitor receives the identical file, so there is no cookie header to branch on. Two fixes work: opt that route out of prerendering with 'export const prerender = false', or keep the page static and fetch the user in the browser from an on-demand endpoint, passing the result into a client component.
Does Astro protect login forms against CSRF automatically?
Partly. On pages rendered on demand, Astro compares the origin header the browser sends against the URL the request went to, and answers with a 403 when the two disagree. That behaviour has been on by default since Astro 5, through the security.checkOrigin option, and it covers cross-site form submissions only. You still regenerate the session ID on login, and you still authorize every action handler and API route individually. Setting security.checkOrigin to false disables the check.
Should I use context.rewrite or Astro.redirect to send logged-out users to the login page?
Use context.rewrite in middleware when you want the login content served at the URL the visitor requested, since it keeps the protected path in the address bar and avoids a second browser round trip. Astro.redirect returns a redirect response and the browser navigates to /login. A rewrite kicks off a fresh render and your middleware runs again, so exclude /login from your protected paths or the same check keeps failing.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k