How to add User Authentication to SvelteKit with Lucia and MongoDB

Wed Feb 01 2023

Table of Contents


    In this tutorial, we’ll be adding user authentication to a SvelteKit app using Lucia and MongoDB.

    Lucia is a framework-agnostic user-authentication library you can use with most databases. We’ll be using MongoDB, but you can use any database that Lucia supports. Lucia also supports multiple authentication methods, such as email/password, OAuth, and more. We’ll be using email/password authentication.

    You can find the finished code for this tutorial on Github, and you can view the live demo here.

    Prerequisites

    • A SvelteKit app
    • A MongoDB database (you can use MongoDB Atlas for free).
    • This example uses axios to make requests to the API. You can use a different library if you prefer.
    • For a bit of styling, I use Tailwind and daisyUI. If you choose not to add these, nothing will break, but the styling will be basic.

    Configuring Lucia

    Install Lucia

    Lucia is split into multiple packages:

    • The core package (lucia-auth),
    • MongoDB adapter (@lucia-auth/adapter-mongoose), and
    • SvelteKit adapter (@lucia-auth/sveltekit).

    You can install all of these with npm:

    npm i lucia-auth @lucia-auth/adapter-mongoose @lucia-auth/sveltekit

    If you want to use axios, then run:

    npm i axios

    Initialise Lucia and the MongoDB adapter

    In src/lib/server/lucia.ts, we’ll initialise Lucia and the MongoDB adapter.

    We’ll create a User model to interact with users in the database. The Session model stores information about the user’s session, and the Key model stores information about the user’s password. It can also be used to store other authentication methods, such as OAuth.

    import { dev } from '$app/environment';
    import adapter from '@lucia-auth/adapter-mongoose';
    import lucia from 'lucia-auth';
    import mongoose, { Model } from 'mongoose';
    
    export const User = mongoose.model(
    	'user',
    	new mongoose.Schema(
    		{
    			_id: String, // Will be generated by Lucia
    			email: String // Not strictly required by Lucia, but we'll be using it
    		},
    		// Let Lucia handle the _id field
    		{ _id: false }
    	)
    );
    
    // We don't usually need to interact with the session model,
    //  so don't define a const
    mongoose.model(
    	'session',
    	new mongoose.Schema(
    		{
    			_id: String,
    			user_id: {
    				type: String,
    				required: true
    			},
    			active_expires: {
    				type: Number,
    				required: true
    			},
    			idle_expires: {
    				type: Number,
    				required: true
    			}
    		},
    		{ _id: false }
    	)
    );
    
    mongoose.model(
    	'key',
    	new mongoose.Schema(
    		{
    			_id: String,
    			user_id: {
    				type: String,
    				required: true
    			},
    			// Not strictly required by Lucia, but we'll be using it
    			hashed_password: String,
    			primary: {
    				type: Boolean,
    				required: true
    			}
    		},
    		{ _id: false }
    	)
    );
    
    export const auth = lucia({
    	// Pass the existing mongoose connection to the adapter
    	adapter: adapter(mongoose),
    	// Let Lucia know which environment we're in
    	env: dev ? 'DEV' : 'PROD',
    	// When Lucia returns a user from the database, it will pass it through this function
    	transformUserData: ({ id, email }) => ({
    		userId: id,
    		email
    	})
    });
    
    // Expose the types for the rest of the app
    export type Auth = typeof auth;
    Info
    Note at the end there, we defined a transformUserData function. This is called when Lucia returns a user from the database. It's useful for transforming the user data into a format that's easier to work with.
    In this case, we're just renaming the id field to userId and passing the email field along.

    Declare Lucia types

    Using the exposed Auth type from above, let’s now declare the rest of the types needed on the app. In your src/app.d.ts file, add the following:

    /// <reference types="lucia-auth" />
    declare namespace Lucia {
    	type Auth = import('$lib/server/lucia').Auth;
    	type UserAttributes = {
    		email: string;
    	};
    }
    
    /// <reference types="@sveltejs/kit" />
    declare namespace App {
    	interface Locals {
    		validate: import('@lucia-auth/sveltekit').Validate;
    		validateUser: import('@lucia-auth/sveltekit').ValidateUser;
    		setSession: import('@lucia-auth/sveltekit').SetSession;
    	}
    }

    SvelteKit now knows about Lucia’s types. We’ll be using locals to validate the user’s session and get their data.

    Info
    If, in future, you want to store more info about the user, be sure to add it to the UserAttributes type.

    Create a MongoDB connection and add Lucia hooks

    Next, we need to create a connection to our MongoDB database and add Lucia’s hooks to the SvelteKit app.

    In src/hooks.server.ts, add the following:

    // From our .env file
    import { MONGO_URI } from '$env/static/private';
    import mongoose from 'mongoose';
    import { auth } from '$lib/server/lucia';
    import { handleHooks } from '@lucia-auth/sveltekit';
    
    export const handle = handleHooks(auth);
    
    try {
    	await mongoose.connect(MONGO_URI, {
    		autoIndex: false,
    		dbName: 'test' // If you're using a different database name, change this
    	});
    } catch (error) {
    	console.log(error);
    }

    If you have other hooks you want to run, use SvelteKit’s sequence function to run multiple.

    Warning
    Make sure Lucia's hook is first!
    import { sequence } from '@sveltejs/kit/hooks';
    import { auth } from '$lib/server/lucia';
    import { handleHooks } from '@lucia-auth/sveltekit';
    
    export const handle = sequence(handleHooks(auth), customHandle);

    Setup session refresh

    Next, we’ll use Lucia’s handleSession function to refresh active user sessions, even across tabs! We add the following in src/routes/+layout.svelte:

    <script lang="ts">import { page } from "$app/stores";
    import { handleSession } from "@lucia-auth/sveltekit/client";
    handleSession(page);
    </script>
    
    <slot />
    Warning
    Be sure that you're not subscribing to the page store.
    In other words, use page, not $page.

    Pass the session data from server to client

    Now, in our src/+layout.server.ts file, we’ll use Lucia’s handleServerSession function to send the session data to the client.

    import { handleServerSession } from '@lucia-auth/sveltekit';
    
    export const load = handleServerSession();

    This adds the session and user data on the $page.data object available to the client.

    If you want to hook into this load function, simply pass in your own function as the first argument.

    import { handleServerSession } from '@lucia-auth/sveltekit';
    import type { LayoutServerLoadEvent } from './$types';
    
    export const load = handleServerSession((event: LayoutServerLoadEvent) => {
    	// Do something here
    	// Lucia has exposed event.locals for use in this function
    });

    Using Lucia

    Setup a sign up page

    Let’s create a sign up page. In src/routes/signup/+page.svelte, we’ll add the following:

    <script lang="ts">import axios from "axios";
    let email;
    let password;
    let err;
    const signup = async () => {
      err = "";
      try {
        const { data } = await axios.postForm("", {
          email,
          password
        });
        if (data.type === "success") {
          email = password = "";
          window.location.href = "/";
        } else
          err = "Something went wrong";
      } catch (error) {
        console.log(error);
        err = error?.response?.data?.error?.message;
      }
    };
    $:
      if (email || password)
        err = "";
    </script>
    
    <form on:submit|preventDefault={signup}>
    	<input
    		class="input input-sm"
    		type="email"
    		autocomplete="email"
    		placeholder="Email"
    		bind:value={email}
    	/>
    	<input
    		class="input input-sm"
    		type="password"
    		autocomplete="new-password"
    		placeholder="Password"
    		bind:value={password}
    	/>
    
    	<button class="my-4 btn btn-sm btn-primary" type="submit"> Signup </button>
    
    	{#if err}
    		<div class="text-error">{err}</div>
    	{/if}
    </form>

    The form will look something like this:

    Signup page

    On submit, the form posts its data to the /signup action, which we’ll create next.

    In src/routes/signup/+page.server.ts, add the following:

    import { auth } from '$lib/server/lucia';
    import { error, redirect } from '@sveltejs/kit';
    import type { Actions, PageServerLoad } from './$types';
    
    // On page load, redirect authenticated users to the home page
    export const load: PageServerLoad = async ({ locals }) => {
    	const session = await locals.validate();
    	if (session) throw redirect(302, '/');
    	return {};
    };
    
    export const actions: Actions = {
    	default: async ({ request, locals }) => {
    		const form = await request.formData();
    		const email = form.get('email');
    		const password = form.get('password');
    
    		// Validate form data
    		if (typeof email !== 'string' || typeof password !== 'string') {
    			throw error(400, 'Invalid email or password');
    		}
    
    		try {
    			const user = await auth.createUser({
    				key: {
    					// The authentication provider we're using
    					providerId: 'email',
    					// The user's email
    					providerUserId: email,
    					password
    				},
    				attributes: {
    					email
    				}
    			});
    
    			// Create and set the session cookies
    			const session = await auth.createSession(user.userId);
    			locals.setSession(session);
    		} catch {
    			// Email already taken
    			throw error(409, 'Email already taken');
    		}
    	}
    };
    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 user's password is at least 8 characters long.

    Setup a signin page

    Let’s create a sign in page. In src/routes/signin/+page.svelte, we’ll add the following:

    <script lang="ts">import axios from "axios";
    let email;
    let password;
    let err;
    const signin = async () => {
      err = "";
      try {
        const { data } = await axios.postForm("", {
          email,
          password
        });
        if (data.type === "redirect") {
          email = password = "";
          window.location.href = data.location;
        }
      } catch (error) {
        console.log(error);
        err = error?.response?.data?.error?.message;
      }
    };
    $:
      if (email || password)
        err = "";
    </script>
    
    <form on:submit|preventDefault={signin}>
    	<input
    		class="input input-sm"
    		type="email"
    		autocomplete="email"
    		placeholder="Email"
    		bind:value={email}
    	/>
    	<input
    		class="input input-sm"
    		type="password"
    		autocomplete="current-password"
    		placeholder="Password"
    		bind:value={password}
    	/>
    
    	<button class="my-4 btn btn-primary" type="submit"> Sign in </button>
    
    	{#if err}
    		<div class="text-error">{err}</div>
    	{/if}
    </form>

    This page should look similar to the signup:

    Signup page

    The form will post its data to the /signin action, which we’ll create next.

    In src/routes/signin/+page.server.ts we’ll add the following:

    import { auth } from '$lib/server/lucia';
    import { error, redirect, 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 form data
    		if (typeof email !== 'string' || typeof password !== 'string') {
    			throw error(400, 'Invalid email or password');
    		}
    
    		try {
    			// Lucia handles the authentication for us
    			const { userId } = await auth.validateKeyPassword('email', email, password);
    
    			// Create and set the session cookies
    			const session = await auth.createSession(userId);
    			locals.setSession(session);
    		} catch (e) {
    			throw error(404, 'Invalid email or password');
    		}
    
    		// If the user was redirected to the sign in page,
    		//   redirect them back to the page they were on.
    		//   Otherwise, redirect them to the home page
    		throw redirect(302, url.searchParams.get('redirect') ?? '/');
    	}
    };

    Use user data in a page

    Let’s create a page that shows the user’s info. In src/routes/profile/+page.svelte, we’ll user getUser to access Lucia’s user store:

    <script lang="ts">import { getUser } from "@lucia-auth/sveltekit/client";
    const user = getUser();
    </script>
    
    <h1 class="text-lg my-3">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>
    {:else}
    	<p>Not signed in</p>
    {/if}

    The page should look like this:

    Profile page

    Route guards

    If a user is unauthenticated, we want to block them from accessing certain pages and API routes.

    To block users accessing pages, we can authorise them in that route’s +page.server.ts load function. For example, to block unauthorized users accessing the profile page, we can add the following to src/routes/profile/+page.server.ts:

    import { redirect } from '@sveltejs/kit';
    import type { PageServerLoad } from './$types';
    
    export const load: PageServerLoad = async ({ locals, url }) => {
    	const session = await locals.validate();
    	// If the user is unauthenticated, redirect them to the sign in page
    	//  and pass the current page's URL as a query parameter
    	//  so we can redirect them back to it after they sign in
    	if (!session) throw redirect(302, `/signin?redirect=${url.pathname}`);
    
    	// Validated users can access the page
    	return {};
    };

    To block users accessing an API route, we can authorise them in that route’s +server.ts function. For example, in src/routes/api/secret/+server.ts:

    import { auth } from '$lib/server/lucia';
    import { json, error, type RequestHandler } from '@sveltejs/kit';
    
    export const GET: RequestHandler = async ({ locals }) => {
    	const session = await locals.validate();
    	if (!session) throw error(401, 'Not authenticated');
    
    	return json({ meaning: 42 });
    };

    Now, when calling fetch('/api/secret') from the frontend, we’ll get a 401 error if the user is unauthenticated.

    Signing out

    To sign out, we’re going to set up an API route which invalidates the current user’s session.

    In src/routes/api/signout/+server.ts, add the following:

    import { auth } from '$lib/server/lucia';
    import { error, redirect } from '@sveltejs/kit';
    import type { RequestHandler } from './$types';
    
    export const POST: RequestHandler = async ({ locals }) => {
    	const session = await locals.validate();
    	if (!session) throw error(401, 'Not authenticated');
    
    	await auth.invalidateSession(session.sessionId); // Invalidate db session
    	locals.setSession(null); // Remove cookie
    
    	throw redirect(302, '/signin');
    };

    On the frontend, we can call this API route from a button.

    src/lib/components/SignOutButton.svelte:

    <script lang="ts">import axios from "axios";
    import { invalidateAll } from "$app/navigation";
    </script>
    
    <button
    	class="btn btn-sm btn-ghost"
    	on:click={async () => {
    		await axios.post('/api/signout');
    		await invalidateAll();
    	}}
    >
    	Sign out
    </button>

    After clicking the button, the user will be signed out and redirected to the sign in page.

    Conclusion

    Lucia is a simple, lightweight authentication library for SvelteKit. It’s designed to be easy to use and integrate with SvelteKit, and to be flexible enough to work with any database.

    In this tutorial, we’ve covered how to set up Lucia with SvelteKit and MongoDB, how to create a sign in form, how to use user data in a page, how to block unauthenticated users from accessing certain pages and API routes, and how to sign out.

    Custom Database Adapters
    If your preferred database isn't supported, you can write your own adapter using Lucia's API.

    If you have any questions, feel free to ask them in the Lucia Discord server!

    You can view the completed code here on Github, and the live demo is available here.

    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.