Use OTPs and Lucia to secure your SvelteKit app with Password-Reset, Email-Verification, and more!

Sun Feb 12 2023

Table of Contents


    In this post, I’ll show you how to use Lucia to implement password reset and email verification in your SvelteKit app using OTPs.

    Prerequisites

    • A SvelteKit app
    • A MongoDB database
    • Basic email/password authentication
    • A way to send emails
      • You can use Mailtrap for free dev testing
      • The link for the email and password-reset will also be console logged for testing

    You can find the finished code for this tutorial here.

    Overview

    In this tutorial, we’ll be using Lucia to implement password reset and email verification in our SvelteKit app using OTPs.

    An OTP (one-time pin/password) is a token that can only be used once. It’s a great way to verify a user’s identity without having to store a password in the database.

    The problem we’re solving here - using password-reset as an example - is that we want to allow users to reset their password without having to log in. If they’ve forgotten their password, we can’t use that to verify their identity, so we need another way. Given that we know their email address, we can send them an email with a link to reset their password. The link will contain a random token that only they have access to. When they click the link, we can verify that the token is valid and that it hasn’t expired, and then allow them to reset their password.

    Mongoose Model

    When we implement OTPs, we’ll need to store them in the database. This way, when the user tries to reset their password, we can check if the token they’ve provided is valid and hasn’t expired.

    So, let’s create a Mongoose model for our OTPs. In src/lib/models/OTPs.ts:

    // This comes from the Lucia startup guide
    import { auth } from '$lib/server/lucia';
    import mongoose from 'mongoose';
    
    // Every OTP has the following properties
    export interface OTPBase {
    	// The user that the OTP is for
    	userId: string;
    	// The token that the user will use to verify the OTP
    	token: string;
    	// An optional expiry date for the OTP
    	expiresAt?: Date;
    }
    
    // OTPs can be of different kinds
    // We use this to prevent one token from being used for multiple purposes
    export interface EmailVerificationOTP extends OTPBase {
    	kind: 'email-verification';
    }
    
    export interface PasswordResetOTP extends OTPBase {
    	kind: 'password-reset';
    }
    
    export type OTP = EmailVerificationOTP | PasswordResetOTP;
    
    const modelName = 'OTPs';
    export const OTPs =
    	mongoose.models[modelName] ||
    	mongoose.model<OTP>(
    		modelName,
    		new mongoose.Schema(
    			{
    				userId: {
    					type: String,
    					required: true,
    					ref: 'user'
    				},
    				token: {
    					type: String,
    					required: true,
    					// Use the crypto Web API to generate a random token
    					default: () => crypto.randomUUID()
    				},
    				expiresAt: {
    					type: Date
    				},
    				kind: {
    					type: String,
    					required: true,
    					enum: ['email-verification', 'password-reset']
    				}
    			},
    			{ timestamps: true }
    		),
    		modelName
    	);

    We now have a way to store OTPs in the database. Importantly, we’ve also added a kind property to the OTPs. This is so that we can prevent one token from being used for multiple purposes. For example, if we had a kind of password-reset, we could use that token to reset a user’s password, but we couldn’t use it to verify their email address.

    Utility Functions

    Next, we’ll create some utility functions to help us with our OTPs. You can add these in a different file, but I’ve added them to src/lib/models/OTPs.ts:

    /**
     * Check if an OTP is expired.
     *   If it doesn't have an expiry date, it's never expired
     */
    export const isOPTExpired = <T extends { expiresAt?: Date }>(otp: T) => {
    	if (otp.expiresAt === undefined) return false;
    	else return otp.expiresAt.getTime() < Date.now();
    };
    
    /**
     * Return an existing OTP if it exists and is not expired,
     *   or create a new one if it doesn't exist or is expired.
     */
    export const getExistingOrNewOTP = async (options: {
    	userId: string;
    	kind: OTP['kind'];
    	expiresAt?: Date;
    }) => {
    	const { userId, kind, expiresAt } = options;
    
    	// Check if there is an existing OTP for that user of that kind
    	const existing = await OTPs.findOne({ userId, kind }).exec();
    
    	if (existing) {
    		if (isOPTExpired(existing)) {
    			console.log('Existing OTP expired, creating new one');
    			const [newOTP, _removeOld] = await Promise.all([
    				OTPs.create({ userId, kind, expiresAt }),
    				existing.remove()
    			]);
    
    			return newOTP;
    		} else {
    			console.log('Existing OTP not expired, returning it');
    			return existing;
    		}
    	} else {
    		console.log('No existing OTP, creating new one');
    		return OTPs.create({ userId, kind, expiresAt });
    	}
    };
    
    /**
     * Given a token, and the kind of OTP, returns the user and the OTP if it exists and is not expired.
     *
     * If the OTP is expired, it will be deleted.
     *
     * If the user is not found, the OTP will be deleted.
     */
    export const validateOTP = async (token: string, kind: OTP['kind']) => {
    	const otp = await OTPs.findOne({ token, kind }).exec();
    	if (!otp) {
    		console.log('OTP not found');
    		return { ok: <const>false };
    	}
    
    	if (isOPTExpired(otp)) {
    		console.log('OTP expired');
    		await otp.remove();
    		return { ok: <const>false };
    	}
    
    	const user = await auth.getUser(otp.userId);
    	if (!user) {
    		console.log('User not found');
    		await otp.remove();
    		return { ok: <const>false };
    	}
    
    	return { ok: <const>true, user, otp };
    };

    We’ll use getExistingOrNewOTP to create a new OTP for a user when they request a password reset or email verification.

    validateOTP will let us validate an OTP when the user tries to verify it.

    Password Reset

    The general idea of password reset is that the user will enter their email address, and we’ll send them an email with a link and a random, secret token. When they click the link, they’ll be taken to a page where they can enter a new password. Then we’ll validate the token, and update their password.

    Create the Password Reset OTP

    First, let’s setup a page where the user can enter their email address and request a password reset. In src/routes/forgot-password/+page.svelte:

    <script lang="ts">import axios from "axios";
    let email;
    let err = "";
    let suc = "";
    const forgotPassword = async () => {
      err = suc = "";
      try {
        const { data } = await axios.postForm("", { email });
        if (data.type === "success")
          suc = "Check your email for a link to reset your password.";
        else
          err = "There was an error sending the email.";
      } catch (error) {
        console.log(error);
        err = error?.response?.data?.error?.message;
      }
    };
    $:
      if (email)
        err = suc = "";
    </script>
    
    <form on:submit|preventDefault={forgotPassword}>
    	<input class="input" type="email" autocomplete="email" placeholder="Email" bind:value={email} />
    
    	<button class="my-4 btn btn-primary" type="submit" disabled={!email}>
    		Send Password Reset Email
    	</button>
    
    	{#if err}
    		<div class="text-error text-sm">{err}</div>
    	{:else if suc}
    		<div class="text-success text-sm">{suc}</div>
    	{/if}
    </form>

    Then, in that route’s actions, we’ll create the OTP and send the email. This is in the src/routes/forgot-password/+page.server.ts file.

    import { auth } from '$lib/server/lucia';
    import { getExistingOrNewOTP } from '$lib/models/OTPs';
    import { type Actions, error } from '@sveltejs/kit';
    
    export const actions: Actions = {
    	default: async ({ request, url }) => {
    		const form = await request.formData();
    		const email = form.get('email');
    		if (typeof email !== 'string') throw error(400, 'Invalid email');
    
    		const { user } = await auth.getKeyUser('email', email);
    		if (!user) {
    			// Don't reveal whether the email exists or not
    			return { ok: true };
    		}
    
    		const { userId } = user;
    		const OTP = await getExistingOrNewOTP({
    			userId,
    			kind: 'password-reset',
    			// One day from now
    			expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24)
    		});
    
    		const href = `${url.origin}/reset-password?token=${OTP.token}`;
    		console.log(href);
    		console.log('TODO: sendEmail');
    
    		return { ok: true };
    	}
    };
    Warning
    Note how we return { ok: true } even if the user doesn't exist. This avoids revealing whether the email exists or not.
    The downside is that, if a real user accidentally mistypes their email address, they won't get an email, even though we say we sent one.

    Use the Password Reset OTP

    The user can now generate a password reset OTP by entering their email address. When they click the link, they’ll be taken to the src/routes/reset-password/+page.svelte route, where they can enter a new password.

    <script lang="ts">import axios from "axios";
    let newPass;
    let confirmPass;
    let err = "";
    let suc = "";
    const resetPassword = async () => {
      if (newPass !== confirmPass)
        return err = "Passwords do not match";
      err = suc = "";
      try {
        const { data } = await axios.postForm("", { newPass });
        if (data.type === "success") {
          suc = "Password changed successfully";
          window.location.href = "/signin";
        } else
          err = "Something went wrong";
      } catch (error) {
        console.log(error);
        err = error?.response?.data?.error?.message;
      }
    };
    $:
      if (newPass || confirmPass)
        err = suc = "";
    </script>
    
    <form on:submit|preventDefault={resetPassword}>
    	<input
    		class="input"
    		type="password"
    		autocomplete="new-password"
    		placeholder="New Password"
    		bind:value={newPass}
    	/>
    	<input
    		class="input"
    		type="password"
    		autocomplete="new-password"
    		placeholder="Confirm Password"
    		bind:value={confirmPass}
    	/>
    
    	<button class="my-4 btn btn-primary" type="submit" disabled={!newPass || !confirmPass}>
    		Reset Password
    	</button>
    
    	{#if err}
    		<div class="text-error text-sm">{err}</div>
    	{:else if suc}
    		<div class="text-success text-sm">{suc}</div>
    	{/if}
    </form>

    This form will get sent to src/routes/reset-password/+page.server.ts, where we’ll validate the OTP and change the user’s password.

    import { auth } from '$lib/server/lucia';
    import { validateOTP } from '$lib/models/OTPs';
    import { error, type Actions } from '@sveltejs/kit';
    
    export const actions: Actions = {
    	default: async ({ request, url }) => {
    		const form = await request.formData();
    		const newPass = form.get('newPass');
    		const token = url.searchParams.get('token');
    
    		// Validate the form data and token
    		if (typeof newPass !== 'string') throw error(400, 'Invalid form data');
    		if (typeof token !== 'string') throw error(400, 'Invalid token');
    
    		const check = await validateOTP(token, 'password-reset');
    		if (!check.ok) throw error(400, 'Invalid token');
    
    		const { user, otp } = check;
    		try {
    			await auth.updateKeyPassword('email', user.email, newPass);
    			// Remove the OTP so it can't be used again
    			await otp.remove();
    
    			return { ok: true };
    		} catch (err) {
    			console.log(err);
    			throw error(500, 'Something went wrong');
    		}
    	}
    };
    Advanced Form Validation
    The form validation in this example is pretty basic. If you want to improve on it, check out Zod, and add some stronger, custom validation checks.
    For example, you can use it to ensure the new password is at least 8 characters long.

    Let’s test it out. Go to /forgot-password and enter an email address. If the email address exists, we’ll see a success message. If it doesn’t, there’ll be an error message.

    Then, check the server console to see the link to the password reset page. Copy that link and paste it into the browser, and we should see the password reset form.

    Enter a new password and confirm it. Hit send, and we’ll be redirected to the sign-in page. Try login with your old password, and you’ll see that it doesn’t work. Now try logging in with your new password, and you’ll be authenticated.

    Email Verification

    The last thing we’ll add is email verification. When a user signs up, they’ll be sent an email with a link to verify their email address.

    This flow works very similarly to the password reset flow. We’ll create an OTP for email verification, send it to the user, and then validate it when they click the link.

    Storing the Email Verification Status

    One difference is that we need to store the emailVerified status in the database. Let’s first change our src/app.d.ts types to reflect the new field. In your existing types, update the UserAttributes type to include the emailVerified field.

    type UserAttributes = {
    	email: string;
    	emailVerified: boolean;
    };

    Next, we add an emailVerified field to the User model, and update the Lucia transformUserData function to return this field. In src/lib/server/lucia.ts, our existing User model should now look like this:

    export const User: Model<DBUser> =
    	mongoose.models['user'] ||
    	mongoose.model(
    		'user',
    		new mongoose.Schema(
    			{
    				_id: String,
    				email: String,
    				// Add the emailVerified field
    				emailVerified: {
    					type: Boolean,
    					required: true,
    					default: false
    				}
    			},
    			{ _id: false }
    		)
    	);
    
    // ... other models
    
    export const auth = lucia({
    	adapter: adapter(mongoose),
    	env: dev ? 'DEV' : 'PROD',
    	transformUserData: ({ id, email, emailVerified }) => ({
    		userId: id,
    		email,
    		// Add the emailVerified field
    		emailVerified
    	})
    });

    Create the Email Verification OTP

    When a user signs up, we’ll create an OTP for email verification and send them an email with a link to verify their email address.

    Nothing needs to change in the src/routes/signup/+page.svelte file, but we’ll need to add some code to src/routes/signup/+page.server.ts.

    import { auth } from '$lib/server/lucia';
    import { OTPs } from '$lib/models/OTPs';
    import { error, json, type Actions } from '@sveltejs/kit';
    
    export const actions: Actions = {
    	default: async ({ request, locals, url }) => {
    		const form = await request.formData();
    		const email = form.get('email');
    		const password = form.get('password');
    
    		// Validate the form data
    		if (typeof email !== 'string' || typeof password !== 'string')
    			throw error(400, 'Invalid form data');
    
    		try {
    			const { userId } = await auth.createUser({
    				key: {
    					providerId: 'email',
    					providerUserId: email,
    					password
    				},
    				attributes: {
    					email,
    					// Add the emailVerified field
    					emailVerified: false
    				}
    			});
    
    			// If successful, we know there are no existing email-verification OTPs,
    			//   since we just created the user.
    			//   So we can create a new one without checking for existing ones.
    			const otp = await OTPs.create({
    				userId,
    				kind: 'email-verification',
    				// One day from now
    				expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24)
    			});
    			const href = `${url.origin}/api/verify-email?token=${otp.token}`;
    			console.log(href);
    			console.log('TODO: sendEmail');
    
    			const session = await auth.createSession(userId);
    			locals.setSession(session);
    
    			return json({ ok: true });
    		} catch (e) {
    			const { message } = e as Error;
    			if (message === 'AUTH_DUPLICATE_KEY_ID' || message === 'AUTH_DUPLICATE_USER_DATA')
    				throw error(400, 'Email already in use');
    
    			throw error(500, 'Something went wrong');
    		}
    	}
    };

    Now, when a user signs up, we’ll create an OTP for email verification and send them an email with a link to verify their address.

    Use the Email Verification OTP

    When a user clicks the link in their email, we’ll validate the OTP and set the emailVerified field to true.

    We’ll add a new API route, src/routes/api/verify-email/+server.ts, to handle this.

    import { auth } from '$lib/server/lucia';
    import { validateOTP } from '$lib/models/OTPs';
    import { error, redirect, type RequestHandler } from '@sveltejs/kit';
    
    export const GET: RequestHandler = async ({ url }) => {
    	const token = url.searchParams.get('token');
    
    	// Validate the token
    	if (typeof token !== 'string') throw error(400, 'Invalid token');
    
    	const check = await validateOTP(token, 'email-verification');
    	if (!check.ok) throw error(400, 'Invalid token');
    
    	const { user, otp } = check;
    
    	await auth.updateUserAttributes(user.userId, {
    		emailVerified: true
    	});
    
    	await otp.remove();
    
    	throw redirect(302, '/');
    };

    This route is triggered when the user clicks the link. It then validates their token, updates the emailVerified field, and redirects them to the home page.

    Let’s test it out. We’ll go to /signup and enter an email address. If the email address exists, we’ll see an error message. If it doesn’t, there’ll be a success message.

    Then, check the console to see the link to the email verification page. Copy that link and paste it into the browser, and we should see a success message and be redirected to the home page.

    If you check that user in the database, you’ll see that the emailVerified field is now true.

    You can also update the src/routes/profile/+page.svelte file to show the email verification status.

    <script lang="ts">import { getUser } from "@lucia-auth/sveltekit/client";
    const user = getUser();
    </script>
    
    <h1>Profile</h1>
    {#if $user}
    	<!-- Here we have access to the data returned by auth.transformUserData -->
    	<p>User id: {$user?.userId}</p>
    	<p>Email: {$user?.email}</p>
    	<p>Email Verified: {$user?.emailVerified}</p>
    {:else}
    	<p>Not signed in</p>
    {/if}
    Extra Features to Add
    There are a few extra features we could add to make this app more useful:
    • A route guard to prevent users with unverified emails from accessing the app
    • Use OTPs as magic signin links

    Conclusion

    That’s it! We’ve now added multipurpose OTPs to our SvelteKit app, and used them to implement password reset and email verification flows.

    You can compare your code against the completed demo here.

    Again, check out the Lucia Discord server if you have any questions or feedback.

    Image of Ross Keenan

    Ross Keenan

    Hi there! I'm a fullstack developer, data-analyst enthusiast, and yoga teacher. Currently living in South Africa, I'm always looking to work on new projects and meet new people.