Password reset links in SvelteKit

This guide expects access to the user’s verified email. See Sign in with email and password with verification links guide to learn how to verify the user’s email, and email and password authentication in general.

// $lib/server/lucia.ts
export const auth = lucia({
	adapter: ADAPTER,
	env: dev ? "DEV" : "PROD",
	middleware: sveltekit(),

	getUserAttributes: (data) => {
		return {
			email: data.email,
			emailVerified: data.email_verified
		};
	}
});

export type Auth = typeof auth;

Clone project#

The email and password SvelteKit example includes password reset.

npx degit lucia-auth/examples/sveltekit/email-and-password <directory_name>

Alternatively, you can open it in StackBlitz.

Database#

Password reset token#

Create a new password_reset_token table. This will have 3 fields.

nametypeprimaryreferencesdescription
idstringToken to send inside the reset link
expiresbigint (unsigned 8 bytes)Expiration (in milliseconds)
user_idstringuser(id)

We’ll be storing the expiration date as a bigint since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native timestamp type. Just make sure to adjust the expiration check accordingly.

Password reset tokens#

The token will be sent as part of the reset link.

http://localhost:5173/password-reset/<token>

When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we’ll validate the token stored in the url and update the password of the user’s key.

Create new tokens#

generatePasswordResetToken() will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using generateRandomString() with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40).

// $lib/server/token.ts
import { generateRandomString, isWithinExpiration } from "lucia/utils";

const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours

export const generatePasswordResetToken = async (userId: string) => {
	const storedUserTokens = await db
		.table("password_reset_token")
		.where("user_id", "=", userId)
		.getAll();
	if (storedUserTokens.length > 0) {
		const reusableStoredToken = storedUserTokens.find((token) => {
			// check if expiration is within 1 hour
			// and reuse the token if true
			return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2);
		});
		if (reusableStoredToken) return reusableStoredToken.id;
	}
	const token = generateRandomString(63);
	await db
		.insertInto("password_reset_token")
		.values({
			id: token,
			expires: new Date().getTime() + EXPIRES_IN,
			user_id: userId
		})
		.executeTakeFirst();
	return token;
};

Validate tokens#

validatePasswordResetToken() will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with isWithinExpiration(), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds).

It will throw if the token is invalid.

// $lib/server/token.ts
import { generateRandomString, isWithinExpiration } from "lucia/utils";

const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours

export const generatePasswordResetToken = async (userId: string) => {
	// ...
};

export const validatePasswordResetToken = async (token: string) => {
	const storedToken = await db.transaction().execute(async (trx) => {
		const storedToken = await trx
			.table("password_reset_token")
			.where("id", "=", token)
			.get();
		if (!storedToken) throw new Error("Invalid token");
		await trx
			.table("password_reset_token")
			.where("id", "=", storedToken.id)
			.delete();
		return storedToken;
	});
	const tokenExpires = Number(storedToken.expires); // bigint => number conversion
	if (!isWithinExpiration(tokenExpires)) {
		throw new Error("Expired token");
	}
	return storedToken.user_id;
};

Create routes/password-reset/+page.svelte and add a form with an input for the email.

<!-- routes/password-reset/+page.svelte -->
<script lang="ts">
	import { enhance } from "$app/forms";
</script>

<h1>Reset password</h1>
<form method="post" use:enhance>
	<label for="email">Email</label>
	<input name="email" id="email" /><br />
	<input type="submit" />
</form>

Create routes/password-reset/+page.server.ts and define a new form action.

Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user’s inbox.

// routes/password-reset/+page.server.ts
import { auth } from "$lib/server/lucia";
import { fail } from "@sveltejs/kit";
import { generatePasswordResetToken } from "$lib/server/token";
import { sendPasswordResetLink } from "$lib/server/email";

import type { Actions } from "./$types";

export const actions: Actions = {
	default: async ({ request }) => {
		const formData = await request.formData();
		const email = formData.get("email");
		// basic check
		if (!isValidEmail(email)) {
			return fail(400, {
				message: "Invalid email"
			});
		}
		try {
			const storedUser = await db
				.table("user")
				.where("email", "=", email.toLowerCase())
				.get();
			if (!storedUser) {
				return fail(400, {
					message: "User does not exist"
				});
			}
			const user = auth.transformDatabaseUser(storedUser);
			const token = await generatePasswordResetToken(user.userId);
			await sendPasswordResetLink(token);
			return {
				success: true
			};
		} catch (e) {
			return fail(500, {
				message: "An unknown error occurred"
			});
		}
	}
};

Reset password#

Create routes/password-reset/[token]/+page.svelte and add a form with an input for the new password.

<!-- routes/password-reset/[token]/+page.svelte -->
<script lang="ts">
	import { enhance } from "$app/forms";
</script>

<h1>Reset password</h1>
<form method="post" use:enhance>
	<label for="password">New Password</label>
	<input name="password" id="password" /><br />
	<input type="submit" />
</form>

Create routes/password-reset/[token]/+page.server.ts and define a new form action.

Get the token from the url with params.token and validate it with validatePasswordResetToken(). Update the key password with Auth.updateKeyPassword(), and optionally verify the user’s email. Make sure you invalidate all user sessions with Auth.invalidateAllUserSessions() before updating the password.

// routes/password-reset/[token]/+page.server.ts
import { auth } from "$lib/server/lucia";
import { fail, redirect } from "@sveltejs/kit";
import { validatePasswordResetToken } from "$lib/server/verification-token";

import type { Actions } from "./$types";

export const actions: Actions = {
	default: async ({ request, params, locals }) => {
		const formData = await request.formData();
		const password = formData.get("password");
		// basic check
		if (
			typeof password !== "string" ||
			password.length < 6 ||
			password.length > 255
		) {
			return fail(400, {
				message: "Invalid password"
			});
		}
		try {
			const { token } = params;
			const userId = await validatePasswordResetToken(token);
			let user = await auth.getUser(userId);
			await auth.invalidateAllUserSessions(user.userId);
			await auth.updateKeyPassword("email", user.email, password);
			if (!user.emailVerified) {
				user = await auth.updateUserAttributes(user.userId, {
					email_verified: Number(true)
				});
			}
			const session = await auth.createSession({
				userId: user.userId,
				attributes: {}
			});
			locals.auth.setSession(session);
		} catch (e) {
			return fail(400, {
				message: "Invalid or expired password reset link"
			});
		}
		throw redirect(302, "/");
	}
};