Lucia

Email verification links

We recommend using email verification codes instead as it's more user-friendly.

Update database

User table

Add a email_verified column (boolean).

import { Lucia } from "lucia";

export const lucia = new Lucia(adapter, {
	sessionCookie: {
		attributes: {
			secure: env === "PRODUCTION" // set `Secure` flag in HTTPS
		}
	},
	getUserAttributes: (attributes) => {
		return {
			emailVerified: attributes.email_verified,
			email: attributes.email
		};
	}
});

declare module "lucia" {
	interface Register {
		Lucia: typeof lucia;
		DatabaseUserAttributes: {
			email: string;
			email_verified: boolean;
		};
	}
}

Email verification token table

Create a table for storing for email verification tokens.

column type attributes
id string primary key
user_id string
email string
expires_at Date

Create verification token

The token should be valid for at most few hours and linked to a single email.

import { TimeSpan, createDate } from "oslo";

async function createEmailVerificationToken(userId: string, email: string): Promise<string> {
	// optionally invalidate all existing tokens
	await db.table("email_verification_token").where("user_id", "=", userId).deleteAll();
	const tokenId = generateId(40);
	await db.table("email_verification_token").insert({
		id: tokenId,
		user_id: userId,
		email,
		expires_at: createDate(new TimeSpan(2, "h"))
	});
	return tokenId;
}

When a user signs up, set email_verified to false, create and send a verification token, and create a new session. You can either store the token as part of the pathname or inside the search params of the verification endpoint.

import { generateId } from "lucia";

app.post("/signup", async () => {
	// ...

	const userId = generateId();

	await db.table("user").insert({
		id: userId,
		email,
		hashed_password: hashedPassword,
		email_verified: false
	});

	const verificationToken = await createEmailVerificationToken(userId, email);
	const verificationLink = "http://localhost:3000/email-verification/" + verificationToken;
	await sendVerificationEmail(email, verificationLink);

	const session = await lucia.createSession(userId, {});
	const sessionCookie = lucia.createSessionCookie(session.id);
	return new Response(null, {
		status: 302,
		headers: {
			Location: "/",
			"Set-Cookie": sessionCookie.serialize()
		}
	});
});

When resending verification emails, make sure to implement rate limiting based on user ID and IP address.

Verify token and email

Extract the email verification token from the URL and validate by checking the expiration date and email. If the token is valid, invalidate all existing user sessions and create a new session. Make sure to invalidate all user sessions.

import { isWithinExpirationDate } from "oslo";

app.get("email-verification/:token", async () => {
	// ...

	// check your framework's API
	const verificationToken = params.token;

	await db.beginTransaction();
	const token = await db
		.table("email_verification_token")
		.where("id", "=", verificationToken)
		.get();
	if (token) {
		await db.table("email_verification_token").where("id", "=", token.id).delete();
	}
	await db.commit();

	if (!token || !isWithinExpirationDate(token.expires_at)) {
		return new Response(null, {
			status: 400
		});
	}
	const user = await db.table("user").where("id", "=", token.user_id).get();
	if (!user || user.email !== token.email) {
		return new Response(null, {
			status: 400
		});
	}

	await lucia.invalidateUserSessions(user.id);
	await db.table("user").where("id", "=", user.id).update({
		email_verified: true
	});

	const session = await lucia.createSession(user.id, {});
	const sessionCookie = lucia.createSessionCookie(session.id);
	return new Response(null, {
		status: 302,
		headers: {
			Location: "/",
			"Set-Cookie": sessionCookie.serialize()
		}
	});
});