How to do email and password authentication with SvelteKit and Lucia
By Justin Ahinon.
Table of Contents
Authentication might be one of the most common features of modern web applications. It is a critical component of any website, as it allows users to log in and access their personalized content.
While many advocate for using a third-party authentication / user management platform, I believe that it is important to understand the underlying technology and how it can be implemented in SvelteKit.
In this article, we will explore how to implement email and password authentication in SvelteKit using the Lucia authentication library.
What is Lucia?
Lucia is an open-source, lightweight and highly customizable session-based authentication library for TypeScript applications. It's built and maintained by Pilcrow . The library provides a set of primitives and utilities for building secure and scalable authentication systems. It's framework and database agnostic, and provides providers and adapters for popular databases like PostgreSQL, MySQL, and SQLite.
For this tutorial, we'll use SQLite as a database alongside the BetterSqlite3 adapter.
To quickly spin up authentication UIs and forms, we'll use the Shadcn Svelte component library.
We'll handle interactions with the database using Drizzle ORM .
Setting up the project
Let's start by creating a new SvelteKit project using the create-svelte
command:
pnpx create-svelte@latest auth-email-password-lucia
This will create a new project directory with the name auth-email-password-lucia
. Let's also use the Node adapter for our project.
cd auth-email-password-lucia
pnpm i -D @sveltejs/adapter-node
We can now go ahead and replace the adapter in the svelte.config.js
file with the Node adapter:
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
...
Let's also install and setup Tailwind CSS, Shadcn Svelte, as well as a few components for our authentication UI.
pnpx @svelte-add/tailwindcss@latest --typography false
pnpm dlx shadcn-svelte@latest init
pnpm dlx shadcn-svelte@latest add button input label card
Setting up the database
Note : Checkout this article for a full guide on how to set up SQLite with Drizzle in a SvelteKit application .
Let's think a little bit about the database structure we'll need for our authentication system. Obviously, we'll need a table for users. Since Lucia uses sessions, we'll also need a table for storing sessions.
Let's now use Drizzle to create the database setup. You can read more about Drizzle and SQLite here .
pnpm add drizzle-orm better-sqlite3 dotenv
pnpm add -D drizzle-kit @types/better-sqlite3
We can now create a Drizzle config file .
// src/lib/server/db/drizzle.config.ts
import * as dotenv from "dotenv";
import type { Config } from "drizzle-kit";
dotenv.config();
export default {
schema: "./src/lib/server/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_URL,
},
out: "./src/lib/server/db/migrations",
} satisfies Config;
We are using an environment variable, DB_URL
, to store the database URL. Let's not forget to add it to the .env
file.
DB_URL=src/lib/server/db/sqlite.db
We also need to create a “client”, that allows use to interact with the database in a type safe way.
// src/lib/server/db/client.ts
import Database from "better-sqlite3";
import * as dotenv from "dotenv";
import { drizzle } from "drizzle-orm/better-sqlite3";
dotenv.config();
const sqlite = new Database(process.env.DB_URL);
const db = drizzle(sqlite);
export { db, sqlite };
Now, let's create the database schema.
// src/lib/server/db/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { generateId } from "./utils";
const timestamp = {
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
};
const users = sqliteTable("users", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => generateId(15)),
email: text("email").unique().notNull(),
hashedPassword: text("hashed_password").notNull(),
...timestamp,
});
const sessions = sqliteTable("sessions", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => generateId(15)),
expiresAt: integer("expires_at").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id),
...timestamp,
});
export { sessions, users };
That's it for our schema. The users table has an email and a hashed password columns. The sessions table has an expiresAt
column and a userId
column which references the users table.
Using Drizzle Kit generate command, we can generate the SQL migration file for this schema.
It'll look like this:
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`user_id` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`hashed_password` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
To apply the migrations (and create the database), we can use the Drizzle push command .
Let's make sure to git ignore it the database file, to avoid committing our development database.
# .gitignore
...
src/lib/server/db/sqlite.db
Finally, let's create a few functions for CRUD operations on the database.
// src/lib/server/db/users.ts
import { db } from "$lib/server/db/client";
import { users } from "$lib/server/db/schema";
import { eq } from "drizzle-orm";
const getUserByEmail = async (email: string) => {
return db.select().from(users).where(eq(users.email, email)).get();
};
const createNewUser = async (data: typeof users.$inferInsert) => {
return (await db.insert(users).values(data).returning())[0];
};
export { createNewUser, getUserByEmail };
Setup Lucia
We can now set up Lucia in our project. Let's start by installing the necessary dependencies.
pnpm install lucia @lucia-auth/adapter-sqlite
The next step is to create a configuration file for Lucia. Let's do it in the src/lib/server/auth/lucia.ts
file.
// src/lib/server/auth/lucia.ts
import { dev } from "$app/environment";
import { sqlite } from "$lib/server/db/client";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import { Lucia } from "lucia";
interface DatabaseUserAttributes {
email: string;
}
interface DatabaseSessionAttributes {
created_at: Date;
updated_at: Date;
}
const adapter = new BetterSqlite3Adapter(sqlite, {
user: "users",
session: "sessions",
});
const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: !dev,
},
},
getUserAttributes: (attributes) => {
return {
email: attributes.email,
};
},
getSessionAttributes: (attributes) => {
return {
created_at: attributes.created_at,
updated_at: attributes.updated_at,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
DatabaseSessionAttributes: DatabaseSessionAttributes;
}
}
export { lucia };
This is quite a lot, so let's go through it step by step.
We start by instantiating an adapter for Lucia. Lucia uses adapter to interact with the database , mainly to create and read sessions. To this adapter, we pass our SQLite database instance, as well as the table names for users and sessions.
Next one, we create a new instance of Lucia. We pass the adapter we created earlier, as well as some configuration options.
By default, Lucia expects the users
table to have a text id
column, and the sessions
table to have the following columns:
-
id
: a text column with a primary key -
expires_at
: an integer column with a timestamp -
user_id
: a text column with a foreign key to theusers
table
When handling and checking sessions, we might want to return some additional attributes in the user sessions, or the user object that goes with it. For this, we can use the getUserAttributes
and getSessionAttributes
methods and return an object with the attributes we want to include in the session and user objects.
The next step is to set up how Lucia will check for user sessions, validating the sessions, etc…
This is done inside the handle hook in SvelteKit. The handle hook in SvelteKit is a function that runs before and after every request made by the application. It's used to handle things like authentication, authorization and more.
Let's go ahead and create a server hook for our application.
// src/hooks.server.ts
import { lucia } from "$lib/server/auth/lucia";
import type { Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
const LuciaHandle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get(lucia.sessionCookieName);
if (!sessionId) {
event.locals.user = null;
event.locals.session = null;
return await resolve(event);
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: ".",
...sessionCookie.attributes,
});
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: ".",
...sessionCookie.attributes,
});
}
event.locals.user = user;
event.locals.session = session;
return await resolve(event);
};
export const handle: Handle = sequence(LuciaHandle);
The part that interests us here is the LuciaHandle
handle function.
We first check if there is a session cookie in the request. If there is, we validate the session and get the user object. If the session is valid, we set the session cookie and return the response. If the session is not valid, we create a blank session cookie and set it.
Finally, we set the user object and session object in the event.locals
object, which will be available in the rest of the request lifecycle, and across all SvelteKit server files in the application.
Authentication UIs
Now, we can go ahead and create the routes for login and signup. As said earlier, we will use Shadcn Svelte for our UI components.
<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
</script>
<svelte:head>
<title>Sign up</title>
</svelte:head>
<div
class="max-w-5xl mx-auto px-4 py-16 space-y-8 flex justify-center h-screen items-center"
>
<form method="post" use:enhance>
<Card.Root>
<Card.Header>
<Card.Title class="text-2xl">Sign up</Card.Title>
<Card.Description
>Enter your email below to create a new account</Card.Description
>
</Card.Header>
<Card.Content>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="username"
placeholder="[email protected]"
required
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
id="password"
type="password"
name="password"
autocomplete="current-password"
required
/>
</div>
<Button type="submit" class="w-full">Sign up</Button>
</div>
<div class="mt-4 text-center text-sm">
Already have an account?
<a href="/login" class="underline">Login</a>
</div>
</Card.Content>
</Card.Root>
</form>
</div>
This is a basic form with an email and a password fields. We will use the enhance
function from SvelteKit to handle form submissions.
The login form is mostly similar to the signup form, with a few differences for the labels and the submit button text.
When the form is submitted, SvelteKit will expect a corresponding form action to be defined in the associated +page.server.ts
file to the route. Let's create this file and add the action to the form.
// src/routes/signup/+page.server.ts
import { lucia } from "$lib/server/auth/lucia";
import { createNewUser, getUserByEmail } from "$lib/server/db/users";
import { fail, redirect } from "@sveltejs/kit";
import { generateId } from "lucia";
import { Argon2id } from "oslo/password";
export const actions = {
default: async ({ cookies, request }) => {
const formData = Object.fromEntries(await request.formData());
const { email, password } = formData as {
email: string | undefined;
password: string | undefined;
};
if (!email || !password) {
return fail(400, { error: "Email and password are required" });
}
const userId = generateId(15);
const hashedPassword = await new Argon2id().hash(password);
// Check if the email already exists
const user = await getUserByEmail(email);
if (user) {
return fail(400, { error: "Email already exists" });
}
// Create a new user
const newUser = await createNewUser({ id: userId, email, hashedPassword });
// Create a new session and set the session cookie
const session = await lucia.createSession(newUser.id, {
created_at: new Date(),
updated_at: new Date(),
});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, {
path: ".",
...sessionCookie.attributes,
});
redirect(302, "/profile");
},
};
In the form action, we first get the email and the password from the form data. We then proceed with some basic validation to ensure that the email and password are present and not empty.
We also make sure that there is no existing user in the database with the same email address.
After those checks, we create a new user in the DB, and then create a new session for the user. This is where the Lucia adapter is helpful. Behind the scenes, Lucia uses the adapter to create a new session in the database. Once that's done, we set the session cookie and redirect the user to the protected route.
We can also add some logic to this route load function, to redirect the user to the profile page if they are already logged in.
The login form action is very similar to the signup form action.
// src/routes/login/+page.server.ts
// import statements
export const actions = {
default: async ({ cookies, request }) => {
// ...
// Check if the user exists
const user = await getUserByEmail(email);
if (!user) {
return fail(400, { error: "Invalid email or password" });
}
// Verify the password
const validPassword = await new Argon2id().verify(
user.hashedPassword,
password
);
if (!validPassword) {
return fail(400, { error: "Invalid email or password" });
}
// Create a new session and set the session cookie
...
},
};
Instead of creating a new user, we check that there is actually a user in the database with the email address from the form data. We only log in if that user exists and their password is valid.
Frontend feedback for errors
When an error occurs during the sign-up or the login process, we return an action failure with the fail
function (see https://kit.svelte.dev/docs/modules#sveltejs-kit-fail ).
But we don't have (yet) on the frontend a way to let the user know that an error occurs.
Let's implement that feature by adding a toast when an error occurs.
We'll use the sonner
component from Shadcn for that purpose.
pnpm dlx shadcn-svelte@latest add sonner
Now, in our main layout file, we'll add the component and configure it:
<!-- src/routes/+layout.svelte -->
<script>
import { Toaster } from '$shadcn/sonner';
import { ModeWatcher } from 'mode-watcher';
import "../app.css";
</script>
<ModeWatcher defaultMode="light" />
<Toaster richColors={true} />
<slot />
Once that's done, we can call the toaster
function wherever we want to display it:
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
// ...
import { toast } from "svelte-sonner";
export let form;
$: if (form?.error) {
toast.error(form.error);
}
</script>
We'll then get this beautiful error toast whenever there's an error:
We can also do the same thing for sign-up errors.
What's next?
This tutorial was a brief introduction to the basics of SvelteKit and Lucia . There are still a lot more areas that can be covered to improve the current state of the application. Here are some ideas for future work:
-
Implementing more advanced authentication features, such as password reset, email verification, etc…
-
Handling errors and exceptions in the CRUD operations
-
Adding more UI feedbacks to the user when logging in or signing up, for instance
Conclusion
In this tutorial, we dove deep into the world of SvelteKit and Lucia, showcasing how to set up a robust email and password authentication system from scratch. We covered everything from initializing your project and configuring your database to crafting secure login and signup forms with Shadcn Svelte components.
But this is just the beginning! Imagine enhancing your app with advanced features like password resets, email verification, and more user-friendly error handling. The possibilities are endless, and with the foundation you've built today, you're well on your way to creating a secure, scalable, and user-centric web application.
Keep experimenting, keep building, and watch your SvelteKit projects come to life with Lucia!
Stay Updated with the Full Stack SvelteKit course
Want to be the first to know when the Full Stack SvelteKit course launches? Sign up now to receive updates, exclusive content, and early access!