DEV Community

pilcrowOnPaper
pilcrowOnPaper

Posted on • Updated on

SvelteKit JWT authentication tutorial

Update! I created an authentication library called Lucia 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

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 :

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

export const admin = createClient(
    'URL',
    'service_role'
);
Enter fullscreen mode Exit fullscreen mode

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.

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');
}
Enter fullscreen mode Exit fullscreen mode

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.

export const returnError = (status: number, message: string): RequestHandlerOutput => {
    return {
        status,
        body: {
            message
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

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

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');
Enter fullscreen mode Exit fullscreen mode

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

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);
Enter fullscreen mode Exit fullscreen mode

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).

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`
        ]
    }
};
Enter fullscreen mode Exit fullscreen mode

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.

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 = '/';
    }
};
Enter fullscreen mode Exit fullscreen mode

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.

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');
}
Enter fullscreen mode Exit fullscreen mode

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

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');
Enter fullscreen mode Exit fullscreen mode

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

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`
        ]
    }
};
Enter fullscreen mode Exit fullscreen mode

V. Authenticating users

While we can use hooks to read the JWT token (like in this article 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.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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`
        ]
    },
};
Enter fullscreen mode Exit fullscreen mode

VI. Authorizing users

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

// 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
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

VII. Signing out

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

// /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`
            ]
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

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).

const new_refresh_token = randomUUID();
await admin.from('users').update({ refresh_token: new_refresh_token }).eq("refresh_token", refresh_token);
Enter fullscreen mode Exit fullscreen mode

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

Top comments (7)

Collapse
 
orefalo profile image
Olivier Refalo • Edited

Hi,
Thank you for this implementation. it's a good, simple example to highlight how svelte works in the context of authentication.

With that said, I would be careful about using this code in prod.

  • if you pass the refresh token on every call, what the benefit of the token?
  • the refresh token overtakes the expiration of the token, in fact the expiration of the token could be ignored, as it only takes a refresh token to get a new access token.
  • no hooks.js ? isn't that error-prone, potentially leaving sections of the site unsecured. Maybe leveraging the __layout.svelte could be a better alternative.

I found a more mature implementation at
github.com/CloudNativeEntrepreneur...

Of course, it's a different level of complexity.

Thanks again,

Collapse
 
pilcrowonpaper profile image
pilcrowOnPaper • Edited

Hi, thanks for the reply

  • Can you explain why passing the refresh_token on every call negates its benefit?
  • How will that differ from other JWT authentication methods?
  • Can elaborate on why this approach is unsecured? I agree that I should've used getSession().
Collapse
 
orefalo profile image
Olivier Refalo

Conceptually, passing two tokens on every call is not such a great idea. There is a reason for two tokens, otherwise one token would suffice. To better understand the above, consider the following use case (with your code) - assume we never send "token", but always the "refresh_token" - will it work?
If I read the code right, it will - which highlights some of the issues I tried to bring to your attention.

With OIDC the refresh token is ONLY passed when you need to refresh. it's an additional roundtrip, but there are excellent reasons. First if someone intercepts a call, it's only for the time of the window (15mn in your code), second you can place rules around refresh_token regeneration. I believe the refresh token itself is not the same every time it's used.

Anyway - I don't have the spec on top of my head. Just pointing out that your example seems very simple and that it takes some work to get a proper authentication in place.

JWT is not an authentication method, it's just a standard structure. For this tutorial, your solution could be secure and perfectly fine just with one token. I think it's called the session cookie in other places.

No bad feelings, just trying to help bring awareness,

Feel free to disagree,
Sincerely,

Thread Thread
 
pilcrowonpaper profile image
pilcrowOnPaper

Thanks,

I agree that there’s better methods for authentication, and I should be using Redis and session tokens. That said, for my use case, using a simple JWT authentication was enough since I wasn’t handling sensitive information. The reason it uses 2 tokens is that I wanted the ability to revoke a user’s access token, while keeping the wait time for users minimal. Though there are things that I would change now I have a better understanding of SvelteKit…

Collapse
 
3jeissonwee profile image
JeissonWee

Hey sir, try implement this using "event.cookies.set()" and "event.cookies.get()", i have a problem with this methods when i use new Response()

Collapse
 
gbailey4 profile image
gbailey4

@pilcrowonpaper it looks like you're not using the built in Supabase methods for signing in and out. Was there any reason for that? Here's some docs around what's available native in Supabase: supabase.com/docs/guides/auth

Additionally, this approach is very different from a Github issue walking through a similar problem. Curious to see your perspective on that as well as they aren't actually storing in the DB but rather using the built in functionality and using that to populate the cookie.
github.com/supabase/supabase/discu...

Collapse
 
pilcrowonpaper profile image
pilcrowOnPaper

This tutorial could be used with any database, I just used Supabase since it's free. Looking at GitHub discussion, it seems the code relies on auth.onAuthStateChange(), which is only called after the DOM is rendered. And, it looks complicated for how little you're gaining using Supabase auth.