Skip to content
Astro as patterns 4 min read

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

ApproachBest forTrade-off
Manual cookies + sessionsFull control, custom user modelYou own token rotation and storage
Auth.js (auth-astro)OAuth/social login, fast setupOpinionated; less low-level control
LuciaType-safe, framework-agnostic sessionsMore 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 appropriate sameSite value to defend against XSS and CSRF.
  • Resolve the user once in middleware and pass it through context.locals instead of refetching on every page.
  • Keep auth routes out of prerendering with export const prerender = false (or global output: "server").
  • Verify sessions server-side on every protected request; never trust a value sent only from the client.
  • Use a typed App.Locals interface so Astro.locals.user is checked at build time.
  • Prefer a vetted library (Auth.js, Lucia) for OAuth and session rotation rather than hand-rolling token logic.
Last updated June 14, 2026
Was this helpful?