SvelteKit JWT Authorization
Hello, this article is about how to implement authentication in your SvelteKit project. This will be JWT authentication using refresh tokens for added security. We will be using Supabase as our database (PostgreSQL), but the basics should be the same.
How will it work?
When a user registers, we store the user information and password in our database. We will also generate a refresh token and store it both locally and in the database. We will create a JWT token with user information and save it as cookie
. This JWT token will expire in 15 minutes. When it expires, we will check if the refresh token exists and compare it with the one stored in 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 stored in the database (although this can take up to 15 minutes).
Finally, why Supabase and not Firebase? Personally, I think unlimited read/write is much more important than storage size when running a free system. But any database should work.
I. Structure
This project will consist of 3 pages
index.svelte
: Project pagesignin.svelte
: Login pagesignup.svelte
: Registration page
Well, the packages that we will use
supabase
bcrypt
: For password hashingcrypto
: To generateuser id
(UUID)jsonwebtoken
: To create a JWTcookie
: For parsingcookie
from the server
II. supabase
Create a new project. Now create a new table users
(all non-null
).
id
: int8, unique, isIdentityemail
: varchar, uniquepassword
: textusername
: varchar, uniqueuser_id
: uuid, uniquerefresh_token
: text
Go to settings > api. Copy service_role
and URL
. Create supabase-admin.ts
:
import { createClient } from '@supabase/supabase-js';
export const admin = createClient(
'URL',
'service_role'
);
If you are using Supabase, DO NOT use this client (admin
). Create a new client using your anon
key.
III. Creating an account (account)
Create a new endpoint (/api/create-user.ts
). He will be for POST
request, and as body
(bodies) will be required email
, password
and username
.
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 only meant to make the code cleaner. And validateEmail()
just checks if the string has @
because (to my knowledge) we can’t 100% check if an email is valid using a regular expression.
export const returnError = (status: number, message: string): RequestHandlerOutput => {
return {
status,
body: {
message
}
};
};
In any case, let’s make sure email
or username
not yet used.
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');
Then we hash the user’s password (password
) and create a new one user_id
(UUID) and a refresh token that will be stored 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);
Finally, generate a new JWT token. Be sure to choose something random for the key. Make sure you have set safe mode (Secure
) only if you’re in development mode (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`
]
}
};
On our registration page, we can call POST
-request and redirect our user if successful. Be sure to use window.location.href
instead of goto()
otherwise change (set cookie
) will not be applied.
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="https://habr.com/";
}
};
IV. Entrance
We will process the input in /api/signin.ts
. This time we will allow the user to use either their username (username
) or email address (email
). To do this 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');
}
Next, we will compare the entered and 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');
And finally, do the same as when 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`
]
}
};
V. User authentication
Although we can use hooks to read the JWT token (as in this articlewritten by the author), we will not be able to generate (and install) a new JWT token with their help. So, we will call the endpoint, which will read cookie
and validate them, and return the user’s data if it exists. This endpoint will also handle update sessions (refreshing sessions
). This endpoint will be called /api/auth.ts
.
We can get cookie
, and if they are valid – return the user’s data. If they are invalid, the function verify()
will give an error message.
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 check the refresh token with the token in our database. If they are equal, then 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`
]
},
};
VI. User authorization
To authorize the user, we can check if the request was sent from /api/auth
in load
functions.
// 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. User logout
To log out, simply remove the JWT token and the 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`
]
}
};
};
VIII. Revoking access from a user
To revoke access from a user, 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 (JWT expiration date).
const new_refresh_token = randomUUID();
await admin.from('users').update({ refresh_token: new_refresh_token }).eq("refresh_token", refresh_token);
These are the basics, but once you get the hang of it, implementing profile updates and other features should be fairly straightforward.