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
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:
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:
import type { User } from "./lib/server/auth";
declare global {
namespace App {
interface Locals {
user: User | null;
}
}
}
export {};Sign in
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");
}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:
httpOnlykeeps page scripts from reading the session.sameSite: "lax"keeps other sites' forms from sending it.securesends it over HTTPS only, so it's set in production, where the site is served over HTTPS.pathdefaults 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:
import { redirect, type LoadContext } from "@sigil-dev/grimoire";
export function load({ locals }: LoadContext) {
if (!locals.user) throw redirect(303, "/login");
return { user: locals.user };
}import type { LoadContext } from "@sigil-dev/grimoire";
export async function load({ parent }: LoadContext) {
const { user } = await parent();
return { user };
}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
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, "/");
}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:
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}`);