Lucia

Email verification codes

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 code table

Create a table for storing for email verification codes.

column type attributes
id any auto increment, etc
code string
user_id string unique
email string
expires_at Date

Generate verification code

The code should be valid for few minutes and linked to a single email.

import { TimeSpan, createDate } from "oslo";
import { generateRandomString, alphabet } from "oslo/crypto";

async function generateEmailVerificationCode(userId: string, email: string): Promise<string> {
	await db.table("email_verification_code").where("user_id", "=", userId).deleteAll();
	const code = generateRandomString(8, alphabet("0-9"));
	await db.table("email_verification_code").insert({
		user_id: userId,
		email,
		code,
		expires_at: createDate(new TimeSpan(5, "m")) // 5 minutes
	});
	return code;
}

You can also use alphanumeric codes.

const code = generateRandomString(6, alphabet("0-9", "A-Z"));

When a user signs up, set email_verified to false, create and send a verification code, and create a new session.

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 verificationCode = await generateEmailVerificationCode(userId, email);
	await sendVerificationCode(email, verificationCode);

	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 code and email

Make sure to implement throttling to prevent brute-force attacks.

Validate the verification code by comparing it against your database and checking the expiration and email. Make sure to invalidate all user sessions.

import { isWithinExpirationDate } from "oslo";
import type { User } from "lucia";

app.post("/email-verification", async () => {
	// ...
	const { user } = await lucia.validateSession(sessionId);
	if (!user) {
		return new Response(null, {
			status: 401
		});
	}

	const code = formData.get("code");
	if (typeof code !== "string") {
		return new Response(null, {
			status: 400
		});
	}

	const validCode = await verifyVerificationCode(user, code);
	if (!validCode) {
		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()
		}
	});
});

async function verifyVerificationCode(user: User, code: string): Promise<boolean> {
	await db.beginTransaction();
	const databaseCode = await db
		.table("email_verification_code")
		.where("user_id", "=", user.id)
		.get();
	if (!databaseCode || databaseCode.code !== code) {
		await db.commit();
		return false;
	}
	await db.table("email_verification_code").where("id", "=", code.id).delete();
	await db.commit();

	if (!isWithinExpirationDate(databaseCode.expires_at)) {
		return false;
	}
	if (databaseCode.email !== user.email) {
		return false;
	}
	return true;
}