A Lucia auth login page for Next.js
The lucia package is deprecated, and lucia-auth.com now gives you one file of session code instead. This is that file running a Next.js login: a server action checks the password and sets the cookie, and the shy mascot hops when it works.

By buying you agree to the Terms.
- Download right after paying
- Use it in client projects
- Won't run on the listed stack? We fix it or refund you
Ready forCursorv0Lovableshadcn CLI
Use it in Next.js
"use client";
import { useRouter } from "next/navigation";
import ShyEyesLogin from "@/components/ShyEyesLogin";
import { signIn } from "./actions";
export default function LoginCard() {
const router = useRouter();
return (
<ShyEyesLogin
title="Welcome back"
subtitle="Sign in to your account"
forgotHref="/reset-password"
resetAfter={null}
onSubmit={async (email, password) => {
// The server action checks the password and sets the session cookie
const { error } = await signIn(email, password);
if (error) throw new Error(error); // he shakes his head, the message shows
setTimeout(() => router.replace("/dashboard"), 900); // let the happy hop finish
}}
signInError={(e) => (e instanceof Error ? e.message : "Something went wrong. Try again.")}
/>
);
}import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth-session";
import LoginCard from "./login-card";
export default async function LoginPage() {
// Already signed in? Skip the form.
if (await getCurrentSession()) redirect("/dashboard");
return (
<main className="grid min-h-dvh place-items-center bg-[#0b0d12] p-4">
<LoginCard />
</main>
);
}"use server";
import { verify } from "@node-rs/argon2";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import {
createAuthSession,
deleteAuthSession,
deleteSessionCookie,
getCurrentSession,
setSessionCookie,
} from "@/lib/auth-session";
export async function signIn(email: string, password: string): Promise<{ error: string | null }> {
const user = await db.findUserByEmail(email.trim().toLowerCase());
const ok = user !== null && (await verify(user.passwordHash, password.normalize("NFC")));
// One message for both cases, so the form doesn't tell anyone which emails have accounts
if (!user || !ok) return { error: "Wrong email or password." };
const { token } = await createAuthSession(user.id);
await setSessionCookie(token);
return { error: null };
}
export async function signOut() {
const session = await getCurrentSession();
if (session) await deleteAuthSession(session.id);
await deleteSessionCookie();
redirect("/login");
}import { cookies } from "next/headers";
import { cache } from "react";
import { db } from "./db";
// Adapted from Lucia's code/auth_session.ts (Zero-Clause BSD, lucia-auth.com).
// Same session model: id, userId, secretHash, tokenLastVerifiedAt, createdAt.
export interface AuthSession {
id: string;
userId: string;
secretHash: Uint8Array;
tokenLastVerifiedAt: Date;
createdAt: Date;
}
export const SESSION_COOKIE = "auth_session";
export const SESSION_SECONDS = 60 * 60 * 24 * 10; // 10 days, extended while in use
export async function createAuthSession(userId: string) {
const now = new Date();
const id = generateRandomId();
const secret = new Uint8Array(32);
crypto.getRandomValues(secret);
const secretHash = new Uint8Array(await crypto.subtle.digest("SHA-256", secret));
// Lucia's file calls secret.toBase64(), which needs Node 25.
// Buffer gives the same result on the Node versions Next.js runs on today.
const token = id + "." + Buffer.from(secret).toString("base64url");
const session: AuthSession = { id, userId, secretHash, tokenLastVerifiedAt: now, createdAt: now };
await db.insertAuthSession(session);
return { session, token };
}
export async function validateAuthSessionToken(token: string): Promise<AuthSession | null> {
const now = new Date();
const [id, encodedSecret, ...rest] = token.split(".");
if (!id || !encodedSecret || rest.length > 0) return null;
const secret = new Uint8Array(Buffer.from(encodedSecret, "base64url"));
if (secret.byteLength !== 32) return null;
const session = await db.getAuthSession(id);
if (session === null) return null;
if (now.getTime() - session.tokenLastVerifiedAt.getTime() >= SESSION_SECONDS * 1000) {
await db.deleteAuthSession(session.id);
return null;
}
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", secret));
if (!constantTimeEqual(hash, session.secretHash)) return null;
// Used again after an hour or more: push the expiry back
if (now.getTime() - session.tokenLastVerifiedAt.getTime() >= 60 * 60 * 1000) {
session.tokenLastVerifiedAt = now;
await db.updateAuthSession(session);
}
return session;
}
export async function deleteAuthSession(id: string) {
await db.deleteAuthSession(id);
}
// One database read per request, however many components ask
export const getCurrentSession = cache(async (): Promise<AuthSession | null> => {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
return token ? validateAuthSessionToken(token) : null;
});
// Cookies can only be written from a server action, a route handler or proxy.ts
export async function setSessionCookie(token: string) {
(await cookies()).set(SESSION_COOKIE, token, {
path: "/",
maxAge: SESSION_SECONDS,
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});
}
export async function deleteSessionCookie() {
(await cookies()).delete(SESSION_COOKIE);
}
// 16 random bytes, 5 bits each from an alphabet without l, o, 0 and 1: 80 bits
function generateRandomId(): string {
const alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
let id = "";
for (const b of bytes) id += alphabet[b >> 3];
return id;
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
let c = 0;
for (let i = 0; i < a.byteLength; i++) c |= a[i] ^ b[i];
return c === 0;
}import type { AuthSession } from "./auth-session";
// Lucia's file leaves the queries to you, and so does this one.
// Swap each body for Drizzle, Prisma or plain SQL on your user and auth_session tables.
export type User = { id: string; email: string; passwordHash: string };
export const db = {
async findUserByEmail(email: string): Promise<User | null> {
throw new Error("TODO: select id, email, password_hash from user where email = ?");
},
async insertAuthSession(session: AuthSession): Promise<void> {
throw new Error("TODO: insert into auth_session");
},
async getAuthSession(id: string): Promise<AuthSession | null> {
throw new Error("TODO: select * from auth_session where id = ?");
},
async updateAuthSession(session: AuthSession): Promise<void> {
throw new Error("TODO: update auth_session set token_last_verified_at = ? where id = ?");
},
async deleteAuthSession(id: string): Promise<void> {
throw new Error("TODO: delete from auth_session where id = ?");
},
};import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE, SESSION_SECONDS } from "@/lib/auth-session";
// Server Components can read cookies but can't write them, so the cookie's
// expiry is pushed back here on page loads. validateAuthSessionToken moves the database row.
export function proxy(request: NextRequest) {
const response = NextResponse.next();
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (request.method === "GET" && token) {
response.cookies.set(SESSION_COOKIE, token, {
path: "/",
maxAge: SESSION_SECONDS,
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});
}
return response;
}
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth-session";
import { signOut } from "../login/actions";
export default async function Dashboard() {
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<main className="p-8">
<p>Signed in as {session.userId}</p>
<form action={signOut}>
<button type="submit">Sign out</button>
</form>
</main>
);
}Good to know
- lib/auth-session.ts follows Lucia's file closely: the same five session fields, a 10-day session that moves forward when it's used again after an hour, only a SHA-256 hash of the secret stored in the database, and a constant-time compare. Three things changed for Next.js: the cookies() helpers, a deleteAuthSession for sign-out, and Buffer in place of Uint8Array.toBase64().
- Why Buffer: Lucia's file says toBase64() and fromBase64() need Node.js 25. On Node 22 both are undefined, and TypeScript 5.9.3 doesn't know them even with the ESNext lib, so the file copied as-is fails the type-check. Buffer's base64url gives a cookie-safe token on any Node version Next.js runs on.
- Lucia's file warns that a token stored in a cookie needs CSRF protection. Server Actions have it built in. The Next.js docs say they "compare the Origin header to the Host header (or X-Forwarded-Host). If these don't match, the request will be aborted." A route handler that changes data doesn't get that check, so add the Sec-Fetch-Site check from the top of auth_session.ts there.
- Next.js only writes cookies in a Server Function or a route handler (its cookies docs: "HTTP does not allow setting cookies after streaming starts"). So the server action sets the cookie, proxy.ts pushes its expiry back on page loads, and the dashboard only reads it.
- Passwords are checked with Argon2id from @node-rs/argon2 2.2.2. Lucia's author recommends "Argon2id with at least 16MiB of memory, 3 iterations, and 1 degree of parallelism" in his Auth Book, so hash new passwords at sign-up with hash(password, { memoryCost: 19456, timeCost: 3, parallelism: 1 }). Add rate limiting to signIn before launch. Nothing in this snippet slows down password guessing.
- Type-checked in a scratch project against next 16.4.3, react 19.3.1, @types/react 19.3.0, framer-motion 14.6.0, @node-rs/argon2 2.2.2 and TypeScript 5.9.3, with the shipped ShyEyesLogin source. We also ran lib/auth-session.ts on Node 22 with an in-memory store: a new token validated, a changed secret and malformed tokens were rejected, a session used after two hours moved forward, and an 11-day-old one was rejected and deleted. We haven't run the whole app against a real database, and the Argon2 call, the cookie writes and proxy.ts weren't run in a live Next.js server.
- The mascot is one sign-in card. Sign-up, password reset and email verification aren't in it. The Auth Book has a guide for each, and the db.ts stubs show which queries you still have to write.
Is Lucia deprecated? Where things stand in October 2026
- Yes. npm still serves lucia 3.2.2, published October 20, 2024, with the warning "This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate." That migrate page answers 404 now. npm still counted 290,374 downloads in the week to October 22, 2026, so plenty of apps run it.
- The lucia-auth.com home page says "Lucia was deprecated in March 2025" and points to code/auth_session.ts in the GitHub repo as "a complete, single-file replacement for the NPM package." The file is under the Zero-Clause BSD license, so you can copy it without attribution.
- In a post dated July 29, 2026, the author wrote that the website and repository "will only contain the code and documentation for what's necessary to replace the version 3" package. The example projects are archived and the Discord server closes at the end of the year. The longer explanations live in his free Auth Book (auth.pilcrowonpaper.com), whose example apps are written in Go. The same post deprecates Arctic, the OAuth helper Lucia's old guides used for GitHub and Google sign-in.
Lucia v3 calls and what replaces them
- lucia.createSession(userId, {}) becomes createAuthSession(userId). It returns the session and a token shaped id.secret. The token goes in the cookie and only the secret's hash goes in the database.
- lucia.validateSession(sessionId) becomes validateAuthSessionToken(token), or getCurrentSession() in a Server Component. It returns the session or null. The fresh flag is gone: proxy.ts refreshes the cookie on every page load.
- lucia.invalidateSession(id) becomes deleteAuthSession(id). invalidateUserSessions has no counterpart in the file; a delete on auth_session where user_id matches does the same.
- lucia.createSessionCookie and createBlankSessionCookie become setSessionCookie(token) and deleteSessionCookie().
- The session table changes. Lucia v3 stored id, user_id and expires_at. The new one stores id, user_id, secret_hash, token_last_verified_at and created_at, so existing sessions end and everyone signs in once after the switch. Your user table stays as it is.
When Better Auth or Auth.js fits better
- The route on this page suits you if you want Lucia's approach: sessions in your own database and roughly a hundred lines you can read. You also maintain every one of those lines. If you'd rather not, a maintained library is the better choice, and both of these are free.
- Better Auth (better-auth 1.7.12, 8,322,790 downloads in the week to October 22) includes email and password, social sign-in, email verification, password reset and two-factor, and keeps sessions in your database the way Lucia did. Our Better Auth login page wires an animated form to it.
- Auth.js (next-auth 4.24.15, 4,353,174 downloads that week) fits when you mostly need OAuth providers. authjs.dev now says "The Auth.js project is now part of Better Auth," and Better Auth's migration guide says "We continue to handle security patches and critical issues for Auth.js."
- The animated card works with any of the three. It hands your code an email and a password and waits for a promise, so only the server action changes.
Install
- Copy from the zipPut
react/src/componentsintosrc/, thennpm i framer-motion @node-rs/argon2. - Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.
Props
- onSubmit
- (email, password) → promise; throw to shake
- signInError
- message, or a function of the thrown error
- forgotHref
- your reset-password page
- resetAfter
- null keeps the success state
- accent
- button colour
How qodesign compares
- qodesign vs Magic UIMagic UI is best for: landing pages built with shadcn/ui.
- qodesign vs Aceternity UIAceternity UI is best for: teams building many marketing pages who want one big library.
All comparisons, with prices read on each library's own site.
Questions
Is Lucia auth still maintained?
Not as a package. npm marks lucia 3.2.2 deprecated, and lucia-auth.com says Lucia was deprecated in March 2025. The site now gives you one file, auth_session.ts, to copy into your project instead.
Can I keep using lucia 3.2.2?
It still installs, but it gets no updates, and the migrate link in its npm warning answers 404. Moving to the session file keeps the same idea, a session row in your database and a token in a cookie, but the table changes, so people sign in once after the switch.
Why a server action and not a route handler?
Server Actions get Next.js's Origin check against cross-site requests, and they can write cookies. A route handler works too. Then add the Sec-Fetch-Site check from Lucia's file yourself.
Does it work on Next.js 15?
The session code should. Next.js 15 calls the proxy file middleware.ts, with a function named middleware. We only type-checked on Next.js 16.4.3.
Can Cursor or v0 add it for me?
Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.
Can I use it in client work?
Yes, in personal and client projects. You can't resell the code itself as a kit.