Authentication
Authentication in Astro is a server-side concern, which fits Astro’s zero-JS-by-default philosophy perfectly: you read cookies, verify sessions, and guard routes in the frontmatter or in middleware, then send fully rendered HTML to the browser with no client-side auth bundle. This page walks through three layered patterns — managing your own sessions with cookies, centralizing access control in middleware, and delegating the heavy lifting to a library such as Auth.js or Lucia. Authentication requires on-demand rendering, so make sure an SSR adapter is configured before you start.
Enabling server rendering
Sessions and cookies only work when pages render on the server. Set output: "server" (or use export const prerender = false per route) and install an adapter for your host.
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
});
Pages are server-rendered only when SSR is enabled. A statically prerendered page cannot read request cookies, so any route doing auth must opt out of prerendering.
Sessions with cookies
The simplest pattern stores a signed session token in an HTTP-only cookie. After verifying a user’s password, create a session record and set the cookie. Astro 5’s experimental Astro.session API gives you a server-backed store keyed by that cookie automatically, but the manual approach below shows exactly what happens.
// src/pages/api/login.ts
import type { APIRoute } from "astro";
import { verifyPassword, createSession } from "../../lib/auth";
export const prerender = false;
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const data = await request.formData();
const email = String(data.get("email"));
const password = String(data.get("password"));
const user = await verifyPassword(email, password);
if (!user) {
return new Response("Invalid credentials", { status: 401 });
}
const session = await createSession(user.id);
cookies.set("session", session.id, {
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return redirect("/dashboard");
};
The matching login form is a plain .astro page — no client JavaScript, no framework island.
---
// src/pages/login.astro
---
<form method="POST" action="/api/login">
<label>Email <input type="email" name="email" required /></label>
<label>Password <input type="password" name="password" required /></label>
<button type="submit">Sign in</button>
</form>
Guarding routes with middleware
Rather than repeating session checks on every page, resolve the current user once in middleware and attach it to context.locals. Pages then read Astro.locals.user synchronously.
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { getSession } from "./lib/auth";
const PROTECTED = ["/dashboard", "/account"];
export const onRequest = defineMiddleware(async (context, next) => {
const token = context.cookies.get("session")?.value;
const user = token ? await getSession(token) : null;
context.locals.user = user;
const needsAuth = PROTECTED.some((p) => context.url.pathname.startsWith(p));
if (needsAuth && !user) {
return context.redirect("/login");
}
return next();
});
Type locals so every page gets autocomplete on Astro.locals.user.
// src/env.d.ts
declare namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
---
// src/pages/dashboard.astro
const { user } = Astro.locals; // guaranteed by middleware
---
<h1>Welcome, {user!.email}</h1>
Using Auth.js
For OAuth providers (Google, GitHub) and battle-tested token handling, reach for auth-astro, the official Auth.js integration. It wires up sign-in/sign-out endpoints and a session helper.
npx astro add auth-astro
// auth.config.ts
import GitHub from "@auth/core/providers/github";
import { defineConfig } from "auth-astro";
export default defineConfig({
providers: [
GitHub({
clientId: import.meta.env.GITHUB_CLIENT_ID,
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET,
}),
],
});
---
// src/pages/index.astro
import { getSession } from "auth-astro/server";
const session = await getSession(Astro.request);
---
{session ? (
<p>Signed in as {session.user?.email}</p>
<button id="logout">Sign out</button>
) : (
<a href="/api/auth/signin/github">Sign in with GitHub</a>
)}
<script>
import { signOut } from "auth-astro/client";
document.getElementById("logout")?.addEventListener("click", () => signOut());
</script>
Choosing an approach
| Approach | Best for | Trade-off |
|---|---|---|
| Manual cookies + sessions | Full control, custom user model | You own token rotation and storage |
Auth.js (auth-astro) | OAuth/social login, fast setup | Opinionated; less low-level control |
| Lucia | Type-safe, framework-agnostic sessions | More wiring than Auth.js |
Never store passwords in plain text or roll your own crypto. Hash with
bcrypt/argon2, and keep secrets in environment variables — never in client-visible code.
Best practices
- Always set cookies with
httpOnly,secure(in production), and an appropriatesameSitevalue to defend against XSS and CSRF. - Resolve the user once in middleware and pass it through
context.localsinstead of refetching on every page. - Keep auth routes out of prerendering with
export const prerender = false(or globaloutput: "server"). - Verify sessions server-side on every protected request; never trust a value sent only from the client.
- Use a typed
App.Localsinterface soAstro.locals.useris checked at build time. - Prefer a vetted library (Auth.js, Lucia) for OAuth and session rotation rather than hand-rolling token logic.