Full Stack SvelteKit, a full and comprehensive video course that will teach you how to build and deploy full stack web applications using SvelteKit. Full Stack SvelteKit
Buy Now

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 the  users  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.

Signup form for an email and password authentication system with SvelteKit using Lucia

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!