Email verification codes

An alternative way to verify emails is to use one-time passwords. These are generally more user friendly than verification links and magic links as the login process can be continued on the same device/session. However, because one-time passwords are susceptible to brute force attack, proper protection must be implemented.

Database#

verification_code#

nametypeuniquereferencesdescription
idanyPRIMARY KEY
user_idstringuser(id)User id
codestringVerification code
expiresbigintint4 and timestamp type works too

Generate and send verification code#

The verification code should only be valid for a short span of time (3~5 minutes). The recommended length is 8 and hashing it is optional in this case.

import { generateRandomString } from "lucia/utils";

const session = await authRequest.validate();
if (!session) {
	// Unauthorized
	throw new Error();
}
// check if email is already verified
if (session.user.emailVerified) {
	return redirect("/");
}

const code = generateRandomString(8, "0123456789");

await db.transaction((trx) => {
	// delete existing code
	await trx
		.table("verification_code")
		.where("user_id", "=", session.user.userId)
		.delete();
	// create new code
	await trx.table("verification_code").insert({
		code,
		user_id: session.user.userId,
		expires: Date.now() + 1000 * 60 * 5 // 5 minutes
	});
});

await sendVerificationCode(session.user.email, code);

Validate verification codes#

Make sure to prevent brute force attacks by limiting the number of attempts. One simple approach is to double the timeout on each failed attempt (2, 4, 8, 16 seconds…). This example tracks attempts in-memory but can of course be handled by a regular database. Remember to check the expiration when validating the code, and invalidate all user sessions before updating user attributes (email verified status).

const verificationTimeout = new Map<
	string,
	{
		timeoutUntil: number;
		timeoutSeconds: number;
	}
>();
import { isWithinExpiration } from "lucia/utils";

const session = await authRequest.validate();

// prevent brute force by throttling requests
const storedTimeout = verificationTimeout.get(session.user.userId) ?? null;
if (!storedTimeout) {
	// first attempt - setup throttling
	verificationTimeout.set(session.user.userId, {
		timeoutUntil: Date.now(),
		timeoutSeconds: 1
	});
} else {
	// subsequent attempts
	if (!isWithinExpiration(data.timeoutUntil)) {
		throw new Error("Too many requests");
	}
	const timeoutSeconds = storedTimeout.timeoutSeconds * 2;
	verificationTimeout.set(session.user.userId, {
		timeoutUntil: Date.now() + timeoutSeconds * 1000,
		timeoutSeconds
	});
}

const storedVerificationCode = await db.transaction((trx) => {
	const result = await trx
		.table("verification_code")
		.where("user_id", "=", session.user.userId)
		.get();
	if (!result || result.code !== code) {
		throw new Error("Invalid verification code");
	}
	// invalidate code
	await trx.table("verification_code").where("id", "=", result.id).delete();
	return result;
});

if (!isWithinExpiration(storedVerificationCode.expires)) {
	// optionally send a new code instead of an error
	throw new Error("Expired verification code");
}

storedTimeout.delete(session.user.userId);

let user = await auth.getUser(storedVerificationCode.user_id);

await auth.invalidateAllUserSessions(user.userId); // important!

user = await auth.updateUserAttributes(user.userId, {
	email_verified: true // verify email
});

// create session etc