Authentication Patterns in Astro
Implement authentication in Astro: cookie-based sessions, JWT tokens, OAuth flows, protected routes with middleware, and patterns for both SSR and hybrid modes.
What you'll learn
- ✓How to implement cookie-based session authentication in Astro
- ✓How to build a login/signup flow with form actions
- ✓How to protect routes using middleware
- ✓How to integrate OAuth providers like GitHub and Google
- ✓How to handle auth state in both SSR pages and client islands
Prerequisites
- •Astro with server or hybrid output mode
- •Basic HTTP cookie concepts
Auth in a Multi-Page Framework
Authentication in Astro works differently from SPAs. There is no persistent client-side state. Each page request hits the server, where middleware checks for a session cookie. This is actually simpler and more secure than token-based SPA auth because the session token never touches JavaScript.
Browser → Cookie with session ID → Middleware verifies → Astro.locals.user → Page renders
↓ (if invalid)
Redirect to /login Cookie-Based Sessions
Setting Up the Session Store
For production, use a database or Redis. For this tutorial, we will use a simple in-memory Map and note where to swap in a real store.
// src/lib/session.ts
import crypto from 'node:crypto';
interface Session {
userId: string;
email: string;
role: string;
createdAt: number;
expiresAt: number;
}
// In production, replace with Redis or a database table
const sessions = new Map<string, Session>();
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
export function createSession(user: { id: string; email: string; role: string }): string {
const sessionId = crypto.randomBytes(32).toString('hex');
const now = Date.now();
sessions.set(sessionId, {
userId: user.id,
email: user.email,
role: user.role,
createdAt: now,
expiresAt: now + SESSION_DURATION,
});
return sessionId;
}
export function getSession(sessionId: string): Session | null {
const session = sessions.get(sessionId);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(sessionId);
return null;
}
return session;
}
export function deleteSession(sessionId: string): void {
sessions.delete(sessionId);
}
Password Hashing
// src/lib/password.ts
import crypto from 'node:crypto';
export async function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16).toString('hex');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(`${salt}:${derivedKey.toString('hex')}`);
});
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(derivedKey.toString('hex') === key);
});
});
}
Login and Signup Forms
Signup Page
---
// src/pages/signup.astro
import Layout from '@/layouts/Base.astro';
import { hashPassword } from '@/lib/password';
import { createSession } from '@/lib/session';
import { db } from '@/lib/database';
let error = '';
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
const confirmPassword = formData.get('confirmPassword')?.toString();
if (!email || !password) {
error = 'Email and password are required.';
} else if (password.length < 8) {
error = 'Password must be at least 8 characters.';
} else if (password !== confirmPassword) {
error = 'Passwords do not match.';
} else {
// Check if user already exists
const existing = await db.getUserByEmail(email);
if (existing) {
error = 'An account with this email already exists.';
} else {
const hashedPassword = await hashPassword(password);
const user = await db.createUser({ email, password: hashedPassword, role: 'user' });
const sessionId = createSession(user);
Astro.cookies.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return Astro.redirect('/dashboard');
}
}
}
---
<Layout title="Sign Up" description="Create your account">
<main>
<h1>Create an Account</h1>
{error && <p class="error">{error}</p>}
<form method="POST">
<label>
Email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Password
<input type="password" name="password" required minlength="8"
autocomplete="new-password" />
</label>
<label>
Confirm Password
<input type="password" name="confirmPassword" required
autocomplete="new-password" />
</label>
<button type="submit">Sign Up</button>
</form>
<p>Already have an account? <a href="/login">Log in</a></p>
</main>
</Layout>
Login Page
---
// src/pages/login.astro
import Layout from '@/layouts/Base.astro';
import { verifyPassword } from '@/lib/password';
import { createSession } from '@/lib/session';
import { db } from '@/lib/database';
let error = '';
const redirect = Astro.url.searchParams.get('redirect') ?? '/dashboard';
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
if (!email || !password) {
error = 'Email and password are required.';
} else {
const user = await db.getUserByEmail(email);
if (!user || !(await verifyPassword(password, user.password))) {
error = 'Invalid email or password.';
} else {
const sessionId = createSession(user);
Astro.cookies.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return Astro.redirect(redirect);
}
}
}
---
<Layout title="Log In" description="Log in to your account">
<main>
<h1>Log In</h1>
{error && <p class="error">{error}</p>}
<form method="POST">
<label>
Email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Password
<input type="password" name="password" required
autocomplete="current-password" />
</label>
<button type="submit">Log In</button>
</form>
<p>No account? <a href="/signup">Sign up</a></p>
</main>
</Layout>
Logout Endpoint
// src/pages/api/logout.ts
import type { APIRoute } from 'astro';
import { deleteSession } from '@/lib/session';
export const POST: APIRoute = async ({ cookies, redirect }) => {
const sessionId = cookies.get('session')?.value;
if (sessionId) {
deleteSession(sessionId);
}
cookies.delete('session', { path: '/' });
return redirect('/login');
};
Middleware for Route Protection
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { getSession } from '@/lib/session';
const PUBLIC_ROUTES = ['/', '/login', '/signup', '/blog', '/api/health'];
const AUTH_ROUTES = ['/login', '/signup'];
export const onRequest = defineMiddleware(async (context, next) => {
const sessionId = context.cookies.get('session')?.value;
const session = sessionId ? getSession(sessionId) : null;
// Populate locals
context.locals.user = session
? { id: session.userId, email: session.email, role: session.role }
: null;
const pathname = context.url.pathname;
// Redirect logged-in users away from auth pages
if (session && AUTH_ROUTES.some((r) => pathname.startsWith(r))) {
return context.redirect('/dashboard');
}
// Check if route is public
const isPublic = PUBLIC_ROUTES.some((r) => pathname === r || pathname.startsWith(r + '/'));
const isAsset = pathname.startsWith('/_astro/') || pathname.includes('.');
if (!isPublic && !isAsset && !session) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return context.redirect(`/login?redirect=${encodeURIComponent(pathname)}`);
}
return next();
});
OAuth with GitHub
The OAuth Flow
// src/pages/auth/github.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ redirect }) => {
const clientId = import.meta.env.GITHUB_CLIENT_ID;
const redirectUri = import.meta.env.GITHUB_REDIRECT_URI;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state: crypto.randomUUID(),
});
return redirect(`https://github.com/login/oauth/authorize?${params}`);
};
The Callback Handler
// src/pages/auth/github/callback.ts
import type { APIRoute } from 'astro';
import { createSession } from '@/lib/session';
import { db } from '@/lib/database';
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
const code = url.searchParams.get('code');
if (!code) return redirect('/login?error=no_code');
// Exchange code for access token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: import.meta.env.GITHUB_CLIENT_ID,
client_secret: import.meta.env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await tokenResponse.json();
if (!access_token) return redirect('/login?error=token_failed');
// Fetch user profile
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` },
});
const githubUser = await userResponse.json();
// Fetch email if not public
let email = githubUser.email;
if (!email) {
const emailsResponse = await fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${access_token}` },
});
const emails = await emailsResponse.json();
email = emails.find((e: any) => e.primary)?.email;
}
// Find or create user
let user = await db.getUserByGithubId(githubUser.id);
if (!user) {
user = await db.createUser({
email,
githubId: githubUser.id,
name: githubUser.name,
avatar: githubUser.avatar_url,
role: 'user',
});
}
// Create session
const sessionId = createSession(user);
cookies.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return redirect('/dashboard');
};
Auth State in Client Islands
Islands cannot read Astro.locals directly. Pass the user data as props:
---
// src/pages/dashboard.astro
import UserMenu from '@/components/UserMenu';
const user = Astro.locals.user;
---
<UserMenu client:load user={user} />
// src/components/UserMenu.tsx
interface Props {
user: { id: string; email: string; role: string } | null;
}
export default function UserMenu({ user }: Props) {
if (!user) return null;
const handleLogout = async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
};
return (
<div className="user-menu">
<span>{user.email}</span>
<button onClick={handleLogout}>Log out</button>
</div>
);
}
Security Checklist
- httpOnly cookies: The session cookie must have
httpOnly: trueso JavaScript cannot read it. - secure flag: Set
secure: truein production so the cookie only travels over HTTPS. - sameSite: Use
laxorstrictto prevent CSRF.laxallows the cookie on top-level navigations. - CSRF tokens: For forms that mutate data, add a CSRF token. Generate it in middleware, embed in a hidden field, verify on submission.
- Rate limiting: Protect
/loginand/signupfrom brute force with rate limiting middleware. - Password requirements: Enforce minimum length. Consider using a library like
zxcvbnfor strength checking.
// CSRF token generation in middleware
import crypto from 'node:crypto';
// In middleware:
const csrfToken = crypto.randomBytes(32).toString('hex');
context.cookies.set('csrf', csrfToken, { httpOnly: true, sameSite: 'strict' });
context.locals.csrfToken = csrfToken;
<!-- In forms -->
<form method="POST">
<input type="hidden" name="_csrf" value={Astro.locals.csrfToken} />
<!-- other fields -->
</form>
Summary
Authentication in Astro is server-first: middleware reads a session cookie, populates Astro.locals.user, and protects routes before they render. Form-based login and signup use standard POST requests with server-side validation. OAuth providers integrate through redirect flows with callback endpoints. And because the session token lives in an httpOnly cookie, it is invisible to JavaScript, which eliminates an entire class of token-theft attacks.
Related articles
- REST APIs REST API Authentication: API Keys, JWT, and OAuth 2.0
Learn the three most common REST API authentication methods. Compare API keys, JWT tokens, and OAuth 2.0 with working code examples and security best practices.
- Web Web Authentication with OAuth 2.0 and OpenID Connect
Implement secure authentication using OAuth 2.0 and OIDC: authorization code flow with PKCE, ID tokens, scopes, token management, and common security pitfalls.
- GraphQL GraphQL Authentication and Authorization
Implement secure auth in GraphQL using context-based authentication, custom directives, and field-level permission patterns.
- REST APIs REST API Security Checklist: OWASP, Auth, CORS & Input Validation
A practical security checklist for REST APIs covering OWASP API Top 10, authentication, authorization, input validation, CORS, and common vulnerabilities with fixes.