/ astro-integrations / How to Use Lucia with Astro: Complete Guide
astro-integrations 14 min read

How to Use Lucia with Astro: Complete Guide

Step-by-step guide to integrating Lucia with your Astro website.

How to Use Lucia with Astro: Complete Guide

Lucia is a session-based authentication approach for TypeScript. Unlike Auth0 or Clerk, which are full platforms with hosted UIs and user management dashboards, Lucia gives you the building blocks to implement auth yourself. You own the code, the sessions, the database schema, and the entire flow. If you prefer understanding exactly how your auth works instead of relying on a black box, Lucia is for you.

One thing has changed and it matters before you write a single line. The lucia npm package (v3) was deprecated by its maintainer in 2025. The database-adapter model proved too rigid for such a low-level library, so the project pivoted. Lucia is now an open-source learning resource for implementing sessions and authentication from scratch, with code you copy into your own project and own outright. The homepage describes it as "an open source project to provide resources on implementing authentication using JavaScript and TypeScript." The v3 package and adapters still install (latest is lucia@3.2.2), but they are in maintenance-only mode and the docs steer you toward the from-scratch pattern instead. This guide follows the current recommended approach, not the deprecated package.

For Astro developers, that approach integrates naturally with server-side code. You handle sessions in middleware, protect routes, and manage users directly in your database. There is no third-party redirect or widget to style around, and no abstraction layer you cannot see into.

Prerequisites

  • Node.js 18 or newer
  • An Astro project with on-demand rendering enabled (npm create astro@latest)
  • A database (SQLite, PostgreSQL, MySQL, or similar)
  • TypeScript configured (the Lucia approach is TypeScript-first)

This guide was checked against Astro 6.4.2. Astro 6 is the current major version.

Installation

You no longer install a lucia runtime package for the recommended approach. Instead you install two small, runtime-agnostic helpers from the Oslo project for cryptography and encoding, a password hasher, and your database driver.

Core auth helpers plus SQLite via better-sqlite3:

npm install @oslojs/crypto @oslojs/encoding @node-rs/argon2 better-sqlite3
npm install -D @types/better-sqlite3

For PostgreSQL:

npm install @oslojs/crypto @oslojs/encoding @node-rs/argon2 pg
npm install -D @types/pg

If you want OAuth providers (GitHub, Google, and so on), add Arctic, which is the OAuth 2.0 client library the Lucia docs point to:

npm install arctic

Verified latest versions at the time of writing (checked on 2026-05-29 against registry.npmjs.org): @oslojs/crypto@1.0.1, @oslojs/encoding@1.1.0, @node-rs/argon2@2.0.2, better-sqlite3@12.10.0, @types/better-sqlite3@7.6.13, pg@8.21.0, @types/pg@8.20.0, arctic@3.7.0. For Astro itself, the current Node adapter is @astrojs/node@10.1.2.

Note on password hashing. Older Lucia v3 tutorials use Argon2id from the oslo/password package. The current recommendation is @node-rs/argon2 because hashing should use the most performant implementation available for your runtime. The Copenhagen Book, which the Lucia docs cite as the reference for password handling, recommends Argon2id as the default choice (then Scrypt, then Bcrypt for legacy systems).

Configuration

Set up your database schema. You need a user table and a session table. The session ID is the SHA-256 hash of the token, so it is stored as text:

-- For SQLite
CREATE TABLE user (
    id TEXT NOT NULL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    hashed_password TEXT NOT NULL,
    username TEXT NOT NULL
);

CREATE TABLE session (
    id TEXT NOT NULL PRIMARY KEY,
    expires_at INTEGER NOT NULL,
    user_id TEXT NOT NULL REFERENCES user(id)
);

Create the session module. This is the heart of the from-scratch approach and replaces the old new Lucia(adapter, ...) instance. A token is 20 random bytes, base32-encoded. The session ID stored in the database is the hex SHA-256 hash of that token, so a database leak does not hand an attacker valid tokens. Sessions last 30 days and are renewed when they fall within 15 days of expiry:

// src/lib/auth.ts
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import Database from "better-sqlite3";

export const db = new Database("sqlite.db");

export interface Session {
  id: string;
  userId: string;
  expiresAt: Date;
}

export interface User {
  id: string;
  email: string;
  username: string;
}

export type SessionValidationResult =
  | { session: Session; user: User }
  | { session: null; user: null };

export function generateSessionToken(): string {
  const bytes = new Uint8Array(20);
  crypto.getRandomValues(bytes);
  return encodeBase32LowerCaseNoPadding(bytes);
}

export function createSession(token: string, userId: string): Session {
  const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
  const session: Session = {
    id: sessionId,
    userId,
    expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
  };
  db.prepare(
    "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)"
  ).run(session.id, session.userId, Math.floor(session.expiresAt.getTime() / 1000));
  return session;
}

export function validateSessionToken(token: string): SessionValidationResult {
  const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
  const row = db
    .prepare(
      "SELECT session.id, session.user_id, session.expires_at, user.email, user.username FROM session INNER JOIN user ON user.id = session.user_id WHERE session.id = ?"
    )
    .get(sessionId) as
    | { id: string; user_id: string; expires_at: number; email: string; username: string }
    | undefined;

  if (!row) {
    return { session: null, user: null };
  }

  const session: Session = {
    id: row.id,
    userId: row.user_id,
    expiresAt: new Date(row.expires_at * 1000),
  };
  const user: User = { id: row.user_id, email: row.email, username: row.username };

  // Session expired, delete it.
  if (Date.now() >= session.expiresAt.getTime()) {
    db.prepare("DELETE FROM session WHERE id = ?").run(session.id);
    return { session: null, user: null };
  }

  // Within 15 days of expiry, extend by another 30 days.
  if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
    session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
    db.prepare("UPDATE session SET expires_at = ? WHERE id = ?").run(
      Math.floor(session.expiresAt.getTime() / 1000),
      session.id
    );
  }

  return { session, user };
}

export function invalidateSession(sessionId: string): void {
  db.prepare("DELETE FROM session WHERE id = ?").run(sessionId);
}

export function invalidateAllSessions(userId: string): void {
  db.prepare("DELETE FROM session WHERE user_id = ?").run(userId);
}

Add the Astro cookie helpers. These replace the old lucia.createSessionCookie and lucia.createBlankSessionCookie:

// src/lib/cookies.ts
import type { APIContext } from "astro";

export function setSessionTokenCookie(
  context: APIContext,
  token: string,
  expiresAt: Date
): void {
  context.cookies.set("session", token, {
    httpOnly: true,
    sameSite: "lax",
    secure: import.meta.env.PROD,
    expires: expiresAt,
    path: "/",
  });
}

export function deleteSessionTokenCookie(context: APIContext): void {
  context.cookies.set("session", "", {
    httpOnly: true,
    sameSite: "lax",
    secure: import.meta.env.PROD,
    maxAge: 0,
    path: "/",
  });
}

Declare the typed locals so Astro.locals.user and Astro.locals.session are type-safe across your routes. Put this in src/env.d.ts:

// src/env.d.ts
/// <reference types="astro/client" />

declare namespace App {
  interface Locals {
    session: import("./lib/auth").Session | null;
    user: import("./lib/auth").User | null;
  }
}

Configure Astro for on-demand rendering. This is where Astro 6 differs from older guides. The output: 'hybrid' mode was removed in Astro 5, so do not use it. There are two output values now, static (the default) and server. With output: 'server', routes are rendered on demand and you opt individual pages into static prerendering with export const prerender = true. On-demand rendering requires an adapter:

// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
});

If most of your site is static and only the auth routes need a server, keep the default output: 'static', still add the adapter, and mark only the dynamic endpoints and pages without export const prerender = true (or set export const prerender = false on those routes). Either way you need an adapter for the auth endpoints and middleware to run on demand.

Basic Usage

Create a registration endpoint. Note the password hashing now uses @node-rs/argon2 with the parameters the Copenhagen Book recommends as a minimum:

// src/pages/api/auth/register.ts
import type { APIRoute } from "astro";
import { hash } from "@node-rs/argon2";
import {
  db,
  generateSessionToken,
  createSession,
} from "../../../lib/auth";
import { setSessionTokenCookie } from "../../../lib/cookies";

export const prerender = false;

export const POST: APIRoute = async (context) => {
  const formData = await context.request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const username = formData.get("username") as string;

  if (!email || !password || password.length < 8) {
    return new Response("Invalid input", { status: 400 });
  }

  const hashedPassword = await hash(password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });
  const userId = generateSessionToken().slice(0, 24);

  try {
    db.prepare(
      "INSERT INTO user (id, email, hashed_password, username) VALUES (?, ?, ?, ?)"
    ).run(userId, email, hashedPassword, username);

    const token = generateSessionToken();
    const session = createSession(token, userId);
    setSessionTokenCookie(context, token, session.expiresAt);

    return new Response(null, {
      status: 302,
      headers: { Location: "/dashboard" },
    });
  } catch {
    return new Response("Email already exists", { status: 400 });
  }
};

Create a login endpoint:

// src/pages/api/auth/login.ts
import type { APIRoute } from "astro";
import { verify } from "@node-rs/argon2";
import {
  db,
  generateSessionToken,
  createSession,
} from "../../../lib/auth";
import { setSessionTokenCookie } from "../../../lib/cookies";

export const prerender = false;

export const POST: APIRoute = async (context) => {
  const formData = await context.request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const user = db
    .prepare("SELECT * FROM user WHERE email = ?")
    .get(email) as { id: string; hashed_password: string } | undefined;

  if (!user) {
    return new Response("Invalid credentials", { status: 400 });
  }

  const validPassword = await verify(user.hashed_password, password);
  if (!validPassword) {
    return new Response("Invalid credentials", { status: 400 });
  }

  const token = generateSessionToken();
  const session = createSession(token, user.id);
  setSessionTokenCookie(context, token, session.expiresAt);

  return new Response(null, {
    status: 302,
    headers: { Location: "/dashboard" },
  });
};

Protect routes with Astro middleware. This reads the cookie, validates the token, refreshes the cookie when the session was renewed, clears it when invalid, and populates context.locals:

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { validateSessionToken } from "./lib/auth";
import { setSessionTokenCookie, deleteSessionTokenCookie } from "./lib/cookies";

export const onRequest = defineMiddleware(async (context, next) => {
  const token = context.cookies.get("session")?.value ?? null;
  if (token === null) {
    context.locals.user = null;
    context.locals.session = null;
    return next();
  }

  const { session, user } = validateSessionToken(token);
  if (session !== null) {
    setSessionTokenCookie(context, token, session.expiresAt);
  } else {
    deleteSessionTokenCookie(context);
  }

  context.locals.session = session;
  context.locals.user = user;
  return next();
});

Use the user data in pages:

---
// src/pages/dashboard.astro
export const prerender = false;

const user = Astro.locals.user;
if (!user) {
  return Astro.redirect("/login");
}
---

<html>
  <body>
    <h1>Welcome, {user.username}</h1>
    <p>Email: {user.email}</p>
    <form method="post" action="/api/auth/logout">
      <button type="submit">Logout</button>
    </form>
  </body>
</html>

A logout endpoint simply invalidates the session and clears the cookie:

// src/pages/api/auth/logout.ts
import type { APIRoute } from "astro";
import { invalidateSession } from "../../../lib/auth";
import { deleteSessionTokenCookie } from "../../../lib/cookies";

export const prerender = false;

export const POST: APIRoute = async (context) => {
  if (context.locals.session) {
    invalidateSession(context.locals.session.id);
  }
  deleteSessionTokenCookie(context);
  return new Response(null, {
    status: 302,
    headers: { Location: "/login" },
  });
};

Common Errors and Fixes

Using the deprecated lucia package and its adapters. Tutorials that import Lucia from "lucia", call new Lucia(adapter, ...), or install @lucia-auth/adapter-sqlite are following the v3 path that was deprecated in 2025. The package still installs and the v3 docs remain online for migration reference, but new projects should copy the session functions shown above. See the migration notice in the Lucia docs.

output: 'hybrid' throws a config error. Hybrid output was removed in Astro 5. In Astro 6 you have only static and server. Use output: 'server' and add export const prerender = true to any page you want statically generated, or keep output: 'static' and set export const prerender = false on the dynamic routes. See the Astro on-demand rendering guide.

Auth endpoints return static 404 or never run. On-demand rendering requires an adapter. If your API routes and middleware are not executing, confirm you installed and configured an adapter such as @astrojs/node and that the auth routes are not being prerendered. Endpoints under output: 'static' need export const prerender = false.

Cookies not set over HTTP in local dev. The cookie helpers set secure: import.meta.env.PROD. In production over HTTPS this is correct, but if you test against a non-HTTPS host that reports as production, the browser drops the cookie. Verify import.meta.env.PROD is false in your dev environment.

crypto.getRandomValues or crypto.subtle undefined. The Web Crypto API used by the Oslo helpers is available in Node 18 and newer and in modern edge runtimes. On very old Node versions it is missing. Upgrade Node, which is the documented minimum anyway.

Password verify always fails after switching hashers. Hashes from oslo/password Argon2id and @node-rs/argon2 are not interchangeable across arbitrary parameter sets. If you migrate an existing user table, re-hash on next successful login rather than expecting old hashes to validate under new parameters.

Storing the raw token as the session ID. The token goes to the browser cookie; the database stores only its SHA-256 hash as the ID. If you store the token itself, a database leak exposes valid sessions. Always hash with sha256 from @oslojs/crypto/sha2 before the database read or write.

Production Tips

  1. Use Argon2id for password hashing. @node-rs/argon2 is the performant, native implementation the Lucia docs recommend. The minimum parameters from the Copenhagen Book are memoryCost: 19456, timeCost: 2, parallelism: 1, with a 32-byte output.

  2. Rely on built-in session renewal. validateSessionToken extends a session by 30 days once it falls within 15 days of expiry, and the middleware re-sets the cookie when that happens. This keeps active users logged in without manual bookkeeping.

  3. Add rate limiting to auth endpoints. The from-scratch approach does not include rate limiting. The Lucia docs describe a token-bucket pattern you can adapt, or use a library to cap brute-force login attempts.

  4. Use Arctic for OAuth. The arctic package provides OAuth 2.0 clients for GitHub, Google, Discord, and many other providers. It handles the authorization-code exchange so you only configure redirect URIs and persist the returned user.

  5. Use a production-grade database. SQLite via better-sqlite3 is fine for local development and small single-instance deploys. For production, the same session functions port cleanly to PostgreSQL or another server database so sessions persist across restarts and scale across instances.

Alternatives to Consider

  • Clerk if you want a hosted solution with pre-built UI components, social login, and an organization management dashboard.
  • Auth0 if you need enterprise features like SSO, MFA, and compliance certifications without building them yourself.
  • Supabase Auth if you are already using Supabase and want auth that integrates directly with your Postgres database and Row Level Security.

Official Docs and Examples

Wrapping Up

Lucia gives you full control over authentication without building everything from raw primitives. Its current form is a documented, copy-it-into-your-project pattern rather than a package you depend on, which means there is no library to deprecate out from under you again. You own the session functions, the cookie handling, and the database schema. For Astro sites with on-demand rendering where you want to avoid third-party auth services and keep user data in your own database, this approach strikes the right balance between doing it yourself and reinventing the wheel. The learning curve is steeper than hosted solutions, but the result is auth that you understand completely and can customize without limits.

Sources

All versions and facts below were checked on 2026-05-29.