Skip to content
Codeloom
Astro

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.

·8 min read · By Codeloom
Intermediate 14 min read

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
Auth flow in Astro

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

  1. httpOnly cookies: The session cookie must have httpOnly: true so JavaScript cannot read it.
  2. secure flag: Set secure: true in production so the cookie only travels over HTTPS.
  3. sameSite: Use lax or strict to prevent CSRF. lax allows the cookie on top-level navigations.
  4. CSRF tokens: For forms that mutate data, add a CSRF token. Generate it in middleware, embed in a hidden field, verify on submission.
  5. Rate limiting: Protect /login and /signup from brute force with rate limiting middleware.
  6. Password requirements: Enforce minimum length. Consider using a library like zxcvbn for 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.