SvelteKit JWT authentication tutorial
Tags: svelte, javascript, tutorial
Published: 2022-03-31 06:58:00

> Update! I created an authentication library called [Lucia](https://lucia-sveltekit.vercel.app) to solve this problem. It's much more secure than the method use here (but still very flexible) so check it out!

Hello, this article will cover how to implement authentication into your SvelteKit project. This will be a JWT authentication with refresh tokens for added security. We will use Supabase as the database (PostgreSQL) but the basics should be the same.

[Github repository](https://github.com/pilcrowOnPaper/sveltekit-auth-demo)

## Before we start...

### Why?

In my previous post and video, I showed how to implement Firebase authentication. But, at that point, there’s no real advantages of using those services, especially if you don’t need Firestore’s realtime updates. With Supabase offering a generous free tier and a pretty good database, it likely is simpler to create your own. 

### How will it work?

When a user signs up, we will save the user’s info and password into our database. We will also generate a refresh token and save it both locally and in the database. We will create a JWT token with user info and save it as a cookie. This JWT token will expire in 15 minutes. When it expires, we will check if a refresh token exists, and compare it with the one saved inside our database. If it matches, we can create a new JWT token. With this system, you can revoke a user’s access to your website by changing the refresh token saved in the database (though it may take up to 15 minutes). 

Finally, why Supabase and not Firebase? Personally, I felt the unlimited read/writes were much more important than storage size when working with a free tier. But, any database should work.

## I. Set up

This project will have 3 pages: 

- `index.svelte` : Protected page
- `signin.svelte` : Sign in page
- `signup.svelte` : Sign up page

And here’s the packages we’ll be using:

- `supabase`
- `bcrypt` : For hashing passwords
- `crypto` : For generating user ids (UUID)
- `jsonwebtoken` : For creating JWT
- `cookie` : For parsing cookies in the server

## II. Supabase

Create a new project. Now, create a new table called `users` (All non-null) :

- `id` : int8, unique, isIdentity
- `email` : varchar, unique
- `password` : text
- `username` : varchar, unique
- `user_id` : uuid, unique
- `refresh_token` : text

Go to settings > api. Copy your `service_role` and `URL`. Create `supabase-admin.ts` :

```tsx
import { createClient } from '@supabase/supabase-js';

export const admin = createClient(
	'URL',
	'service_role'
);
```

If you’re using Supabase in your front end, DO NOT use this client (`admin`) for it. Create a new client using your `anon` key. 

## III. Creating an account

Create a new endpoint (`/api/create-user.ts`). This will be for a POST request and will require `email`, `password`, and `username` as its body.

```tsx
export const post: RequestHandler = async (event) => {
	const body = (await event.request.json()) as Body;
	if (!body.email || !body.password || !body.username) return returnError(400, 'Invalid request');
	if (!validateEmail(body.email) || body.username.length < 4 || body.password.length < 6)
		return returnError(400, 'Bad request');
}
```

By the way, `returnError()` is just to make the code cleaner. And `validateEmail()` just checks if the input string has `@` inside it, since (to my limited knowledge) we can’t 100% check if an email is valid using regex. 

```tsx
export const returnError = (status: number, message: string): RequestHandlerOutput => {
	return {
		status,
		body: {
			message
		}
	};
};
```

Anyway, let’s make sure the `email` or `username` isn’t already in use.

```tsx
const check_user = await admin
	.from('users')
	.select()
	.or(`email.eq.${body.email},username.eq.${body.username}`)
	.maybeSingle()
if (check_user.data) return returnError(405, 'User already exists');
```

Next, hash the user’s password and create a new user id and refresh token, which will be saved in our database.

```tsx
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(body.password, salt);
const user_id = randomUUID();
// import { randomUUID } from 'crypto';
const refresh_token = randomUUID();
const create_user = await admin.from('users').insert([
	{
		email: body.email,
		username: body.username,
		password: hash,
		user_id,
		refresh_token
	}
]);
if (create_user.error) return returnError(500, create_user.statusText);
```

Finally, generate a new JWT token. Make sure to pick something random for `key`. Make sure to only set `secure` if you’re only in production (localhost is http, not https).

```tsx
const user = {
	username: body.username,
	user_id,
	email: body.email
};
const secure = dev ? '' : ' Secure;';
// import * as jwt from 'jsonwebtoken';
// expires in 15 minutes
const token = jwt.sign(user, key, { expiresIn: `${15 * 60 * 1000}` });
return {
	status: 200,
	headers: {
		// import { dev } from '$app/env';
		// const secure = dev ? '' : ' Secure;';
		'set-cookie': [
			// expires in 90 days
			`refresh_token=${refresh_token}; Max-Age=${30 * 24 * 60 * 60}; Path=/; ${secure} HttpOnly`,
			`token=${token}; Max-Age=${15 * 60}; Path=/;${secure} HttpOnly`
		]
	}
};
```

In our signup page, we can call a POST request and redirect our user if it succeeds. Make sure to use `window.location.href` instead of `goto()` or else the change (setting the cookie) won’t be implemented.

```tsx
const signUp = async () => {
	const response = await fetch('/api/create-user', {
		method: 'POST',
		credentials: 'same-origin',
		body: JSON.stringify({
			email,
			username,
			password
		})
	});
	if (response.ok) {
		window.location.href = '/';
	}
};
```

## IV. Signing in

We will handle the sign in in `/api/signin.ts`. This time, we will allow the user to user either their username or email. To do that, we can check if it is a valid username or email, and check if the same username or email exists.

```tsx
export const post: RequestHandler = async (event) => {
	const body = (await event.request.json()) as Body;
	if (!body.email_username || !body.password) return returnError(400, 'Invalid request');
	const valid_email = body.email_username.includes('@') && validateEmail(body.email_username);
	const valid_username = !body.email_username.includes('@') && body.email_username.length > 3;
	if ((!valid_email && !valid_username) || body.password.length < 6)
		return returnError(400, 'Bad request');
	const getUser = await admin
		.from('users')
		.select()
		.or(`username.eq.${body.email_username},email.eq.${body.email_username}`)
		.maybeSingle()
	if (!getUser.data) return returnError(405, 'User does not exist');
}
```

Next, we will compare the input and the saved password. 

```tsx
const user_data = getUser.data as Users_Table;
const authenticated = await bcrypt.compare(body.password, user_data.password);
if (!authenticated) return returnError(401, 'Incorrect password');
```

And finally, do the same thing as creating a new account.

```tsx
const refresh_token = user_data.refresh_token;
const user = {
	username: user_data.username,
	user_id: user_data.user_id,
	email: user_data.email
};
const token = jwt.sign(user, key, { expiresIn: `${expiresIn * 60 * 1000}` });
return {
	status: 200,
	headers: {
		'set-cookie': [
			`refresh_token=${refresh_token}; Max-Age=${refresh_token_expiresIn * 24 * 60 * 60}; Path=/; ${secure} HttpOnly`,
			`token=${token}; Max-Age=${15 * 60}; Path=/;${secure} HttpOnly`
		]
	}
};
```

## V. Authenticating users

While we can use hooks to read the JWT token (like in this [article](https://dev.to/pilcrowonpaper/saving-users-preferences-in-sveltekit-3b37) I wrote), we can’t generate (and set) a new JWT token with it. So, we will call an endpoint, which will read the cookie and validate it, and return the user’s data if they exist. This endpoint will also handle refreshing sessions. This endpoint will be called `/api/auth.ts`.

We can get the cookie, if valid, return the user’s data. If it isn’t valid, `verify()` will throw an error.

```tsx
export const get: RequestHandler = async (event) => {
	const { token, refresh_token } = cookie.parse(event.request.headers.get('cookie') || '');
	try {
		const user = jwt.verify(token, key) as Record<any, any>;
		return {
			status: 200,
			body: user
		};
	} catch {
		// invalid or expired token
	}
}
```

If the JWT token has expired, we can validate the refresh token with the one in our database. If it is the same, we can create a new JWT token.

```tsx
if (!refresh_token) return returnError(401, 'Unauthorized user');
const getUser = await admin.from('users').select().eq("refresh_token", refresh_token).maybeSingle()
if (!getUser.data) {
	// remove invalid refresh token
	return {
		status: 401,
		headers: {
			'set-cookie': [
				`refresh_token=; Max-Age=0; Path=/;${secure} HttpOnly`
			]
		},
	}
}
const user_data = getUser.data as Users_Table;
const new_user = {
	username: user_data.username,
	user_id: user_data.user_id,
	email: user_data.email
};
const token = jwt.sign(new_user, key, { expiresIn: `${15 * 60 * 1000}` });
return {
	status: 200,
	headers: {
		'set-cookie': [
			`token=${token}; Max-Age=${15 * 60}; Path=/;${secure} HttpOnly`
		]
	},
};
```

## VI. Authorizing users

To authorize a user, we can check send a request to `/api/auth` in the load function.

```tsx
// index.sve;te
// inside <script context="module" lang="ts"/>
export const load: Load = async (input) => {
	const response = await input.fetch('/api/auth');
	const user = (await response.json()) as Session;
	if (!user.user_id) {
		// user doesn't exist
		return {
			status: 302,
			redirect: '/signin'
		};
	}
	return {
		props: {
			user
		}
	};
};
```

## VII. Signing out

To sign out, just delete the user’s JWT and refresh token.

```tsx
// /api/signout.ts
export const post : RequestHandler = async () => {
	return {
    status: 200,
		headers: {
			'set-cookie': [
				`refresh_token=; Max-Age=0; Path=/; ${secure} HttpOnly`,
				`token=; Max-Age=0; Path=/;${secure} HttpOnly`
			]
		}
	};
};
```

## VIII. Revoking user access

To revoke a user’s access, simply change the user’s refresh token in the database. Keep in mind that the user will stay logged in for up to 15 minutes (until the JWT expires).

```tsx
const new_refresh_token = randomUUID();
await admin.from('users').update({ refresh_token: new_refresh_token }).eq("refresh_token", refresh_token);
```

This is the basics, but if you understood this, implementing profile updates and other features should be pretty straight forward. Maybe an article about email verification could be interesting...