DIY Authentication and Authorization in SvelteKit 1 with SQLite

In this post, we look into how we can build our own Authentication and Authorization solution for SvelteKit. We will store and hash user credentials, create sessions with Cookies, and make the session info available to the app through hooks and locals.
If you're looking for a more visual and interactive way to learn about the topic of this post, check out my YouTube video on the same subject.

The DIY approach

Of course there are many off the shelf solutions to do authentication and authorization in SvelteKit. But I think it makes sense sometimes to build an own implementation of something to learn how it works and to understand the underlying concepts.

I do want to point out, that storing credentials yourself leads to great risks. Although we salt and hash the passwords, any sensible information you store comes with the burden of protecting it. You need to make sure that no data leaks to the outside and your systems are always patched. You won’t have that problem with a third-party service like Social-Logins, Auth0, Supabase or Firebase etc.

My setup

I use SvelteKit with TypeScript and highly recommend you do this as well. We can type our data shapes in the backend and later reuse them in the frontend to safely access the data in the UI and make our lives easier in case of type changes.

I combined SvelteKit with SQLite as a database as it is lightweight and easy to maintain and fast. You can read more about SvelteKit + SQLite in my previous post. I will also expand the app from that post to include authentication and authorization. As a node SQLite driver I use better-sqlite3 as I like its synchronous API.

Allow user registration

We first need to allow users to register before we can do anything else.

Users Table and DB Code

We start off by creating a small table to store our users. We will use the username as the primary key and store the password as a salted and hashed string. We also store the roles of users to differentiate between privileges.

1CREATE TABLE
2  `users` (
3    `username` TEXT not null,
4    `password` TEXT not null,
5    `created_at` datetime not null default CURRENT_TIMESTAMP,
6    `roles` TEXT not null, -- : separated list of roles (e.g. admin:moderator)
7    -- you might want to normalize this into a separate table
8    primary key (`username`)
9  );
10

Now we can add a method that creates a new user in the database. We will use the bcrypt library to hash the password as it is terrible to store plain text passwords. A hashing algorithm is a one-way function that transforms the input into a fixed-size cryptographic output. If we later look at the hashes, we can’t tell what the original password was. When the user logs in, we can hash the password they entered and compare it to the stored hash. If they match, we know the user entered the correct password.1

We also want to add a salt before we hash. It is a random value added to the password to ensure that even if multiple users have the same password, the hashed values will be different.2 We set the saltRound to 12 which takes some computing time (~1 sec) which makes brute forcing harder.

src/lib/server/db/index.ts
1export async function createUser(
2  username: string,
3  password: string
4): Promise<void> {
5  const sql = `
6  insert into users (username, password, roles)
7  values ($username, $password, 'admin:moderator')
8`;
9
10  const hashedPassword = await bcrypt.hash(password, 12);
11
12  const stmnt = db.prepare(sql);
13  stmnt.run({ username, password: hashedPassword });
14}
15

Register / Login Page

I created a new page that allows both registration and login. I started with two inputs for username and password and a button to register.

src/routes/login/+page.svelte
1<script lang="ts">
2  import type { ActionData } from './$types';
3</script>
4
5<div class="container">
6  <h1 class="is-size-3 has-text-weight-semibold my-4">Login or Register</h1>
7  <form method="post">
8    <input
9      class="input my-2"
10      type="text"
11      placeholder="Username"
12      name="username"
13      required
14    />
15    <input
16      class="input my-2"
17      type="password"
18      placeholder="Password"
19      name="password"
20      required
21    />
22
23    <button class="button mr-3 mt-4" type="submit" formaction="?/register">
24      Register
25    </button>
26    <button class="button is-primary mt-4" type="submit" formaction="?/login">
27      Login
28    </button>
29  </form>
30</div>
31

We also need to implement the register action in the backend. We will use the createUser method we created earlier.

src/routes/login/+page.server.ts
1import { createUser } from '$lib/server/db';
2import { fail, type Actions } from '@sveltejs/kit';
3
4export const actions: Actions = {
5  register: async ({ request, cookies }) => {
6    const data = await request.formData();
7    const username = data.get('username')?.toString();
8    const password = data.get('password')?.toString();
9
10    if (username && password) {
11      try {
12        createUser(username, password);
13      } catch (err) {
14        return fail(400, { errorMessage: 'Internal Server Error' });
15      }
16    } else {
17      return fail(400, { errorMessage: 'Missing username or password' });
18    }
19  },
20};
21

To make use of the errorMessage thrown by the fail function, we need to add a bit of code to the login page.

src/routes/login/+page.svelte
1<script lang="ts">
2  import type { ActionData } from './$types';
3
4  // receive form data from server
5  export let form: ActionData;
6</script>
7
8<div class="container">
9  <h1 class="is-size-3 has-text-weight-semibold my-4">Login or Register</h1>
10  <form method="post">
11    <input
12      class="input my-2"
13      type="text"
14      placeholder="Username"
15      name="username"
16      required
17    />
18    <input
19      class="input my-2"
20      type="password"
21      placeholder="Password"
22      name="password"
23      required
24    />
25
26    <!-- display error message -->
27    {#if form?.errorMessage}
28    <div class="has-text-danger my-2">{form.errorMessage}</div>
29    {/if}
30
31    <button class="button mr-3 mt-4" type="submit" formaction="?/register">
32      Register
33    </button>
34    <button class="button is-primary mt-4" type="submit" formaction="?/login">
35      Login
36    </button>
37  </form>
38</div>
39

Authentication: Cookies and Sessions

In this section, we will implement the login functionality. We check if the credentials are valid, create a session and store its ID in a cookie.

Validate Credentials

The password hashing library bcrypt also offers a function to compare a plain text password with a hashed one. We can use this to validate the credentials. Note that when the user enters an invalid username, we do a hash to “waste” some time. This makes brute forcing harder.

src/lib/server/db/index.ts
1export async function checkUserCredentials(
2  username: string,
3  password: string
4): Promise<boolean> {
5  const sql = `
6  select password
7    from users
8   where username = $username
9`;
10  const stmnt = db.prepare(sql);
11  const row = stmnt.get({ username });
12  if (row) {
13    return bcrypt.compare(password, row.password);
14  } else {
15    // spend some time to "waste" some time
16    // this makes brute forcing harder
17    // could also do a timeout here
18    await bcrypt.hash(password, 12);
19    return false;
20  }
21}
22

We need to call this function in our form handler for the login button. When the credentials are valid, we call the function performLogin which creates a session. In there, we define a maximum session length of 30 days. We call a createSession function which we will implement later that returns an ID. We store this ID in a cookie with cookies.set.

src/routes/login/+page.server.ts
1import { checkUserCredentials, createUser } from '$lib/server/db';
2import { createSession } from '$lib/server/sesstionStore';
3import { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit';
4
5function performLogin(cookies: Cookies, username: string) {
6  const maxAge = 1000 * 60 * 60 * 24 * 30; // 30 days
7  const sid = createSession(username, maxAge);
8  cookies.set('sid', sid, { maxAge });
9}
10
11export const actions: Actions = {
12  // register: ...
13
14  login: async ({ request, cookies }) => {
15    const data = await request.formData();
16    const username = data.get('username')?.toString();
17    const password = data.get('password')?.toString();
18
19    if (username && password) {
20      const res = await checkUserCredentials(username, password);
21
22      if (!res) {
23        return fail(401, { errorMessage: 'Invalid username or password' });
24      }
25
26      performLogin(cookies, username);
27
28      // redirect to home page
29      throw redirect(303, '/');
30    } else {
31      return fail(400, { errorMessage: 'Missing username or password' });
32    }
33  },
34};
35

Session Store

Now that we stored the session ID in a cookie, we can check if the user is logged in. We also want to store some information about the session, like the username and his roles. For this, we will create a sessionStore module. We will use a simple in-memory store for now. In a big production app you might want to use a cache like Redis.

The in-memory store is just a Map of the type SessionInfo. The key will be the session ID also stored in the user cookie.

In the createSession function we start by creating a random session ID. For that, we use the randomBytes function from node’s internal crybto module. Now we can fetch all the user info we want to store in our session map. In our case, we already get the username from the login form and only need to query his roles from the users table.

src/lib/server/sessionStore/index.ts
1import { randomBytes } from 'node:crypto';
2import { getUserRoles } from '../db';
3
4type SessionInfo = {
5  username: string;
6  roles: string[];
7  invalidAt: number;
8};
9type Sid = string;
10
11const sessionStore = new Map<Sid, SessionInfo>();
12
13function getSid(): Sid {
14  return randomBytes(32).toString('hex');
15}
16
17export function createSession(username: string, maxAge: number): string {
18  let sid: Sid = '';
19
20  do {
21    sid = getSid();
22  } while (sessionStore.has(sid));
23
24  const roles = getUserRoles(username);
25
26  sessionStore.set(sid, {
27    username,
28    roles,
29    invalidAt: Date.now() + maxAge,
30  });
31
32  return sid;
33}
34

We also need to expire the session server-side. For that, we create a small function that loops over all sessions and deletes the ones that are expired. We call this function every hour when a session is created. We do this in a setTimeout to not block the server.

src/lib/server/sessionStore/index.ts
1let nextClean = Date.now() + 1000 * 60 * 60; // 1 hour
2// ...
3function clean() {
4  const now = Date.now();
5  for (const [sid, session] of sessionStore) {
6    if (session.invalidAt < now) {
7      sessionStore.delete(sid);
8    }
9  }
10  nextClean = Date.now() + 1000 * 60 * 60; // 1 hour
11}
12// ...
13
14function createSession //...
15// ...
16
17if (Date.now() > nextClean) {
18  setTimeout(() => {
19    clean();
20  }, 5000);
21}
22
23// ...
24

Hooks and locals

Now that the users have a session cookie, and we have a store that records which session belongs to which user, we might want to use this info in some of our components.

To make this work globally in our app, we can use hooks and locals. We can define our hook in a file called hooks.server.ts in the src folder.

In there, we grab the session ID from the cookie, and get the session data for it. We then store the username and roles in the event.locals object. This object is available in all components and pages.

src/hooks.server.ts
1import { getSession } from '$lib/server/sesstionStore';
2import type { Handle } from '@sveltejs/kit';
3
4export const handle = (async ({ event, resolve }) => {
5  const { cookies } = event;
6  const sid = cookies.get('sid');
7  if (sid) {
8    const session = getSession(sid);
9    if (session) {
10      event.locals.username = session.username;
11      event.locals.roles = session.roles;
12    } else {
13      // remove invalid/expired/unknown cookie
14      cookies.delete('sid');
15    }
16  }
17
18  const response = await resolve(event);
19  return response;
20}) satisfies Handle;
21

For the getSession function, we just return the data of our map if the session is still valid. If the session is invalid, we delete it from the map and return undefined and delete the cookie in the function above.

src/lib/server/sessionStore/index.ts
1export function getSession(sid: Sid): SessionInfo | undefined {
2  const session = sessionStore.get(sid);
3  if (session) {
4    if (Date.now() > session.invalidAt) {
5      console.log('delete invalid session', sid);
6      sessionStore.delete(sid);
7      return undefined;
8    } else {
9      return session;
10    }
11  } else {
12    console.log('session not found', sid);
13    return undefined;
14  }
15}
16

We also to define the types for the event.locals object. We do this in a file called types.d.ts in the src folder.

src/types.d.ts
1declare namespace App {
2  // interface Error {}
3  interface Locals {
4    username?: string;
5    roles?: string[];
6  }
7  // interface PageData {}
8  // interface Platform {}
9}
10

Consuming Locals in our App

Display username in header

The most obvious thing to do is to greet the user in the header so he knows he is logged in. As the header sits in our global +layout.svelte file, we first need to add a +layout.server.ts next to it. In there, we can access the locals and return the username in the load function.

src/routes/+layout.server.ts
1import type { LayoutServerLoad } from './$types';
2
3export const load = (async ({ locals }) => {
4  const { username } = locals;
5
6  return { username };
7}) satisfies LayoutServerLoad;
8

We can then just display the username in the header like with any other value from the load function.

src/routes/+layout.svelte
1<script lang="ts">
2  import type { LayoutServerData } from './$types';
3
4  export let data: LayoutServerData;
5</script>
6
7<header>
8  <nav class="navbar is-primary px-4" aria-label="main navigation">
9    <div class="navbar-brand pr-4">
10      <span class="is-size-3 has-text-weight-semibold">SvelteKit + SQLite</span>
11    </div>
12    <div class="navbar-menu">
13      <div class="navbar-start">
14        <a href="/" class="navbar-item">Home</a>
15      </div>
16    </div>
17
18    <div class="navbar-end">
19      <div class="navbar-item">
20        <div class="buttons">
21          {#if data?.username}
22          <span>Hello {data.username}</span>
23          {:else}
24          <a href="/login" class="button is-primary">Log in</a>
25          {/if}
26        </div>
27      </div>
28    </div>
29  </nav>
30</header>
31<slot />
32

Authorization

As we defined user-roles in our table, we might want to lock some pages behind a role. We can do this by using the load function of the page. In there we can check if the user has the required role and return an error if not.

src/routes/admin/index.server.ts
1import type { PageServerLoad } from './$types';
2import { error } from '@sveltejs/kit';
3
4// store this in a file and import it wherever you need it
5function isAdmin(locals: App.Locals) {
6  if (!locals?.roles || !locals?.roles?.includes('admin')) {
7    throw error(401, 'Unauthorized');
8  }
9}
10
11export const load = (({ locals }) => {
12  isAdmin(locals);
13}) satisfies PageServerLoad;
14

You can of course also check if the username local is empty to only check if the user is logged in. And you can reuse this check in any form or API action handler to check if the user is allowed to do something.

Like we did with the header component, we can also hide specific regions of a page with an if block when we return something like the username or an isAdmin boolean from the load function. But make sure to never return sensitive data from the load function when the user is not supposed to see it.

Logout

To log out, we just need to delete the cookie and redirect the user to the home page. We don’t even need a .svelte file in the logout folder.

src/routes/logout/index.server.ts
1import { deleteSession } from '$lib/server/sesstionStore';
2import { redirect } from '@sveltejs/kit';
3import type { PageServerLoad } from '../$types';
4
5export const load = (({ cookies }) => {
6  const sid = cookies.get('sid');
7  if (sid) {
8    cookies.delete('sid');
9    deleteSession(sid);
10  }
11
12  throw redirect(303, '/');
13}) satisfies PageServerLoad;
14

We can link to this page from our header. Note that I added the data-sveltekit-preload-data and data-sveltekit-reload attributes to the link.

The first tells SvelteKit to not preload the data for this link. This can improve loading times for data rich pages, but would result in a logout if the user hovers over the button for the logout page.

The second tells SvelteKit to reload the page after the link is clicked. This is needed because svelte won’t do the redirect when the user is already on the home page, but we want to refresh the header to again show the login button.

src/routes/+layout.svelte
1{#if data?.username}
2<a
3  href="/logout"
4  class="button is-primary"
5  data-sveltekit-preload-data="off"
6  data-sveltekit-reload
7  >Hello {data.username}, Log out</a
8>
9{:else}
10<a href="/login" class="button is-primary">Log in</a>
11{/if}
12

Also persist session in the database

Unfortunately, the only in-memory solution of the session store can cause some trouble while developing the app. The server seems to restart occasionally, resulting in the session store to be reset, and you being logged out.

This was frustrating me a lot so I additionally store the session data in a database table. This way, the session is persisted and the user will stay logged in even if the server restarts.

This is the table:

1CREATE TABLE
2  sessions (
3    ses_id text primary key,
4    ses_created integer not null default (strftime('%s', 'now') * 1000),
5    ses_expires integer not null,
6    ses_data text not null
7  ) strict
8

We need to add the following database functions:

src/lib/server/db/index.ts
1export function deleteExpiredDbSessions(now: number) {
2  const sql = `
3  delete from sessions
4   where ses_expires < $now
5`;
6
7  const stmnt = db.prepare(sql);
8  stmnt.run({ now });
9}
10
11export function insertDbSession(
12  sid: string,
13  sessionInfo: SessionInfo,
14  expiresAt: number
15) {
16  const sql = `
17  insert into sessions (ses_id, ses_expires, ses_data)
18  values ($sid, $expires, $data)
19`;
20
21  const stmnt = db.prepare(sql);
22  stmnt.run({ sid, expires: expiresAt, data: JSON.stringify(sessionInfo) });
23}
24
25export function deleteDbSession(sid: string) {
26  const sql = `
27  delete from sessions
28   where ses_id = $sid
29`;
30  const stmnt = db.prepare(sql);
31  stmnt.run({ sid });
32}
33
34export function getDbSession(sid: string): SessionInfoCache | undefined {
35  const sql = `
36  select ses_data as data
37       , ses_expires as expires
38    from sessions
39   where ses_id = $sid
40`;
41
42  const stmnt = db.prepare(sql);
43  const row = stmnt.get({ sid }) as { data: string; expires: number };
44  if (row) {
45    const data = JSON.parse(row.data);
46    data.expires = row.expires;
47    return data as SessionInfoCache;
48  }
49  return undefined;
50}
51

We also need to add these types:

src/lib/server/db/types.ts
1export type SessionInfo = {
2  username: string;
3  roles: string[];
4};
5
6export type SessionInfoCache = SessionInfo & {
7  invalidAt: number;
8};
9

And at last, we always need to handle the DB next to our in-memory session store:

src/lib/server/sessionStore.ts
1import { randomBytes } from 'node:crypto';
2import {
3  deleteDbSession,
4  deleteExpiredDbSessions,
5  getDbSession,
6  getUserRoles,
7  insertDbSession,
8} from '../db';
9import type { SessionInfo, SessionInfoCache } from '../db/types';
10
11type Sid = string;
12
13const sessionStore = new Map<Sid, SessionInfoCache>();
14let nextClean = Date.now() + 1000 * 60 * 60; // 1 hour
15
16function clean() {
17  const now = Date.now();
18  for (const [sid, session] of sessionStore) {
19    if (session.invalidAt < now) {
20      sessionStore.delete(sid);
21    }
22  }
23  deleteExpiredDbSessions(now);
24  nextClean = Date.now() + 1000 * 60 * 60; // 1 hour
25}
26
27function getSid(): Sid {
28  return randomBytes(32).toString('hex');
29}
30
31export function createSession(username: string, maxAge: number): string {
32  let sid: Sid = '';
33
34  do {
35    sid = getSid();
36  } while (sessionStore.has(sid));
37
38  const roles = getUserRoles(username);
39
40  const expiresAt = Date.now() + maxAge * 1000;
41
42  const data: SessionInfo = {
43    username,
44    roles,
45  };
46  insertDbSession(sid, data, expiresAt);
47
48  sessionStore.set(sid, {
49    ...data,
50    invalidAt: expiresAt,
51  });
52
53  if (Date.now() > nextClean) {
54    setTimeout(() => {
55      clean();
56    }, 5000);
57  }
58
59  return sid;
60}
61
62export function getSession(sid: Sid): SessionInfo | undefined {
63  if (sessionStore.has(sid)) {
64    return sessionStore.get(sid);
65  } else {
66    const session = getDbSession(sid);
67    if (session) {
68      sessionStore.set(sid, session);
69      return session;
70    }
71  }
72
73  console.log('session not found', sid);
74  return undefined;
75}
76
77export function deleteSession(sid: string): void {
78  sessionStore.delete(sid);
79  deleteDbSession(sid);
80}
81

With that, we are done and implemented a simple login system from scratch (except the hashing). You can check the full code in the GitHub repo.

I hope you liked this tutorial and learned something new. If you need any clarification or have suggestions, please leave a comment below.