Guides: Sessions and auth

Guides

Sessions and auth

This guide adds password sign-in to an app: a sessions table, a hook that reads the session cookie on every request, login and logout actions, and pages only signed-in users can see. It uses Bun's built-in SQLite and password hashing, so there's nothing to install.

Users and sessions

src/lib/server/auth.ts
import { Database } from "bun:sqlite";

const db = new Database(process.env.AUTH_DB ?? "auth.sqlite", { create: true });
db.run(`CREATE TABLE IF NOT EXISTS users (
	id INTEGER PRIMARY KEY,
	email TEXT NOT NULL UNIQUE,
	password TEXT NOT NULL
)`);
db.run(`CREATE TABLE IF NOT EXISTS sessions (
	id TEXT PRIMARY KEY,
	user_id INTEGER NOT NULL,
	expires INTEGER NOT NULL
)`);

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

export const SESSION_DAYS = 30;

export async function createUser(email: string, password: string) {
	const hash = await Bun.password.hash(password);
	db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(email, hash);
}

export async function checkPassword(email: string, password: string): Promise<User | null> {
	const row = db
		.query<{ id: number; email: string; password: string }, [string]>(
			"SELECT * FROM users WHERE email = ?",
		)
		.get(email);
	if (!row || !(await Bun.password.verify(password, row.password))) return null;
	return { id: row.id, email: row.email };
}

export function startSession(userId: number): string {
	const id = crypto.randomUUID();
	const expires = Date.now() + SESSION_DAYS * 86_400_000;
	db.query("INSERT INTO sessions VALUES (?, ?, ?)").run(id, userId, expires);
	return id;
}

export function sessionUser(id: string): User | null {
	return db
		.query<User, [string, number]>(
			`SELECT users.id, users.email FROM sessions
			 JOIN users ON users.id = sessions.user_id
			 WHERE sessions.id = ? AND sessions.expires > ?`,
		)
		.get(id, Date.now());
}

export function endSession(id: string) {
	db.query("DELETE FROM sessions WHERE id = ?").run(id);
}

Bun.password hashes with argon2id and a random salt. The session id is random and means nothing on its own; the table says whose it is and until when.

Read the session on every request

hooks.server.ts in the project root (next to package.json, not in src/) runs before every request is routed. Its handle can put values on event.locals, which load functions and actions receive:

hooks.server.ts
import type { Handle } from "@sigil-dev/grimoire";
import { sessionUser } from "./src/lib/server/auth";

export const handle: Handle = async ({ event, resolve }) => {
	const id = event.cookies.get("session");
	event.locals.user = id ? sessionUser(id) : null;
	return resolve(event);
};

Tell TypeScript what locals holds, in src/app.d.ts:

src/app.d.ts
import type { User } from "./lib/server/auth";

declare global {
	namespace App {
		interface Locals {
			user: User | null;
		}
	}
}

export {};

Sign in

src/routes/login/+page.server.ts
import { fail, redirect, type LoadContext } from "@sigil-dev/grimoire";
import type { Cookies } from "@sigil-dev/grimoire";
import { checkPassword, SESSION_DAYS, startSession } from "../../lib/server/auth";

export function load({ locals }: LoadContext) {
	if (locals.user) throw redirect(303, "/account");
	return {};
}

export async function login({ request, cookies }: LoadContext & { cookies: Cookies }) {
	const form = await request.formData();
	const email = String(form.get("email") ?? "");
	const user = await checkPassword(email, String(form.get("password") ?? ""));
	if (!user) return fail(400, { email, problem: "Wrong email or password." });

	cookies.set("session", startSession(user.id), {
		httpOnly: true,
		sameSite: "lax",
		secure: process.env.NODE_ENV === "production",
		maxAge: SESSION_DAYS * 86_400,
	});
	throw redirect(303, "/account");
}
src/routes/login/+page.tsx
const Login = ({ form }: { form?: { email: string; problem: string } }) => (
	<form method="POST" action="?/login">
		<h1>Sign in</h1>
		{form?.problem && <p role="alert">{form.problem}</p>}
		<input name="email" type="email" autocomplete="username" value={form?.email ?? ""} required />
		<input name="password" type="password" autocomplete="current-password" required />
		<button>Sign in</button>
	</form>
);

export default Login;

The cookie settings matter:

  • httpOnly keeps page scripts from reading the session.
  • sameSite: "lax" keeps other sites' forms from sending it.
  • secure sends it over HTTPS only, so it's set in production, where the site is served over HTTPS.
  • path defaults to /, so the cookie reaches every page.

On a failed sign-in the page renders again with the fail() data as its form prop. Adding use={enhance} to the form does the same without a reload (Form actions).

Protect pages

A layout's server load runs for every page below it, on the first request and on every client-side navigation. Redirecting there protects the whole folder:

src/routes/account/+layout.server.ts
import { redirect, type LoadContext } from "@sigil-dev/grimoire";

export function load({ locals }: LoadContext) {
	if (!locals.user) throw redirect(303, "/login");
	return { user: locals.user };
}
src/routes/account/+page.server.ts
import type { LoadContext } from "@sigil-dev/grimoire";

export async function load({ parent }: LoadContext) {
	const { user } = await parent();
	return { user };
}
src/routes/account/+page.tsx
const Account = ({ data }: { data: { user: { email: string } } }) => (
	<main>
		<h1>Signed in as {data.user.email}</h1>
		<form method="POST" action="/logout?/logout">
			<button>Sign out</button>
		</form>
	</main>
);

export default Account;

A layout's data goes to the layout component, not to the pages inside it. A page's load gets it with await parent(), which returns the data of every layout above it, merged.

Sign out

src/routes/logout/+page.server.ts
import { redirect, type LoadContext } from "@sigil-dev/grimoire";
import type { Cookies } from "@sigil-dev/grimoire";
import { endSession } from "../../lib/server/auth";

export async function logout({ cookies }: LoadContext & { cookies: Cookies }) {
	const id = cookies.get("session");
	if (id) endSession(id);
	cookies.delete("session");
	throw redirect(303, "/");
}
src/routes/logout/+page.tsx
const Logout = () => (
	<form method="POST" action="?/logout">
		<button>Sign out</button>
	</form>
);

export default Logout;

Signing out is a POST, never a link: a GET that ends the session could be triggered by any image on any site. The form carries the CSRF token like every other POST form.

Creating users

A sign-up page is a form action like login that calls createUser. For a first admin account, a script is enough:

scripts/add-user.ts
import { createUser } from "../src/lib/server/auth";

const [email, password] = process.argv.slice(2);
if (!email || !password) throw new Error("usage: bun scripts/add-user.ts EMAIL PASSWORD");
await createUser(email, password);
console.log(`added ${email}`);

Edit this page