Hey! You! This tutorial uses an old version of Supabase and also uses Sapper which is basically dead by now. If you are considering using Svelte and Supabase, look at the SvelteKit adder instead.
As a developer who quite recently discovered Svelte (and now likes it a bunch), I decided to make my portfolio and a new (secret as of now) side-project using Sapper, its little framework. Both of these project require authentication and I thought to myself, why not do it all with server-side instead of client-side like many tutorials show with Firebase.
However, I will be using Supabase instead, as I prefer supporting open-source software, and honestly, it's also super nice and easy to work with! As a matter of fact, my previous post featured it as the database for Umami analytics.
I'll be honest here, I hit quite a few roadblocks during the process, but in the end, all was well and I actually learned a bunch of new stuff!
I will be including explanations to both the functions and reasons why I decided to do it in a certain way in collapsibles as I am not sure everyone will need them, and they also might get quite long.
The project's code is also available here.
Step 1: Setting up Sapper
We'll be basing ourselves off the Sapper template, as it will make our job easier. To setup Sapper, just run these commands:
npx degit "sveltejs/sapper-template#rollup" sapper-supabase-auth
npm i
Now that the required packages are installed, let's install the other packages we'll need, since there's quite a few of them. We can also uninstall polka
, since we'll replace it with express
. I'll explain why later in the tutorial.
npm r polka
npm i express@latest cookie-parser body-parser @supabase/supabase-js
Now open server.js
, where we'll change every mention of polka
by express
:
// server.js
import sirv from 'sirv';
import express from 'express'; // HERE
import compression from 'compression';
import * as sapper from '@sapper/server';
const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';
express() // AND HERE
.use(
compression({ threshold: 0 }),
sirv('static', { dev }),
sapper.middleware()
)
.listen(PORT, err => {
if (err) console.log('error', err);
});
You can now run npm run dev
and open localhost:3000
in your browser. You should be greeted by this:
Explanation
There isn't much explanation required here, however, we did change polka
for express
for a reason. express
' response isn't the same and since we use cookies, I preferred using it since it has an included res.cookie()
function, that polka
doesn't have. If you decide to not swap them, you will either need to include a new package to create cookies or create your own cookie file.
Step 2: Creating the dashboard page and the authentication page
Now that we confirmed that Sapper still works, we can go ahead and create the files we'll be needing for this tutorial. First off, we can go ahead and delete the blog
folder as well as rename the about.svelte
file to dashboard.svelte
. The index.svelte
file will act as a authentication page for simplicity.
In components/Nav.svelte
, we can replace the existing links with our new links:
<nav>
<ul>
<li><a aria-current="{segment === undefined ? 'page' : undefined}" href=".">home</a></li>
<li><a aria-current="{segment === 'dashboard' ? 'page' : undefined}" href="dashboard">dashboard</a></li>
</ul>
</nav>
Authentication and dashboard layout
For the authentication page, we will simply create 2 forms: one for the sign in and another for the sign up.
<!-- index.svelte -->
<script></script>
<style>
* {
margin: 0 auto;
}
section:not(:last-child) {
margin-bottom: 4em;
}
h1 {
margin: 0.5em 0;
font-size: 2.8em;
font-weight: 700;
text-align: center;
text-transform: uppercase;
}
form {
width: 50%;
}
input:not([type="checkbox"]) {
display: block;
width: 100%;
margin-bottom: 0.25em;
padding: 0.5em;
border-radius: 0.5em;
border: 1px #999 solid;
}
label {
display: block;
}
button {
width: 100%;
margin-top: 1em;
padding: 0.5em;
border-radius: 0.25em;
border: 2px rgb(255,62,0) solid;
background-color: rgba(255,62,0,0.1);
color: rgb(255,62,0);
font-size: medium;
transition: background-color 0.1s ease-in-out;
}
button:hover {
background-color: rgba(255,62,0,0.2);
cursor: pointer;
}
</style>
<svelte:head>
<title>Sapper Supabase Auth</title>
</svelte:head>
<section>
<h1>Sign in</h1>
<form>
<input id="email" type="email" placeholder="Email address">
<input id="password" type="password" placeholder="Password">
<label for="remember">
<input id="remember" type="checkbox">
Remember me
</label>
<button type="submit">Sign in</button>
</form>
</section>
<hr>
<section>
<h1>Sign up</h1>
<form>
<input id="email" type="email" placeholder="Email address">
<input id="password" type="password" placeholder="Password">
<label for="remember">
<input id="remember" type="checkbox">
Remember me
</label>
<button type="submit">Sign up</button>
</form>
</section>
As for the dashboard, we will simply have a title and a sign out button.
<!-- index.svelte -->
<script context="module"></script>
<script></script>
<style>
* {
margin: 0 auto;
}
h1 {
margin: 0.5em 0;
font-size: 2.8em;
font-weight: 700;
text-align: center;
text-transform: uppercase;
}
button {
width: 100%;
margin-top: 1em;
padding: 0.5em;
border-radius: 0.25em;
border: 2px rgb(255,62,0) solid;
background-color: rgba(255,62,0,0.1);
color: rgb(255,62,0);
font-size: medium;
transition: background-color 0.1s ease-in-out;
}
button:hover {
background-color: rgba(255,62,0,0.2);
cursor: pointer;
}
</style>
<h1>Our very cool dashboard</h1>
<button>Sign out</button>
Our pages should now look like this:
Step 3: Securing the dashboard
To secure the dashboard, we will be using Sapper's session store, which we can access with the Sapper middleware in the server.js
file. For now, we'll only return false, but later we will populate it with the user's access token.
// server.js
express()
.use(
compression({ threshold: 0 }),
sirv('static', { dev }),
sapper.middleware({
session: async (req, res) => {
return { userToken: false };
}
})
)
.listen(PORT, err => {
if (err) console.log('error', err);
});
In dashboard.svelte
, we can make use of Sapper's preload
function, which we can access in the module
context. We can simply check if the userToken exists in the session, and if it doesn't, we redirect to the authentication page.
<!-- dashboard.svelte -->
<script context="module">
export async function preload(page, session) {
let { userToken } = session;
if (!userToken) return this.redirect(302, '/');
return userToken;
}
</script>
If you now try to access the dashboard page, either by the navbar or by directly entering the link, it should redirect you to the authentication page.
Explanation
Sapper has an included session/store (learn more about stores) that is easily accessible via the middleware in the server. For now, we simply make it an object with a userToken
property that we set to false
. In our dashboard, we use Sapper's included preload
function, where we check the session's userToken
. If it doesn't exist/is equal to false
, we redirect the user to the authentication page. If it does exist, we return the token.
Step 4: Creating the authentication functions
Supabase setup
Head over to Supabase to create your project. Once your project is created, go in the settings, then in in the API section, where you'll be able to find your project URL and key:
Before creating our functions, we should start by creating our Supabase client. In the src
folder, we will create a simple file called supabase.js
that we will import every time we need to use Supabase. I highly suggest putting the Supabase URL and anonymous key in environment variables, for better security.
// supabase.js
import { createClient } from '@supabase/supabase-js';
const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key');
export default supabase;
API endpoints and authentication function
We will now create our internal API endpoint, where we will be able to sign in and sign up. Later, we will also create one where we sign out.
In the src
folder, create a folder named api
, and inside it, create a file called auth.js
. This file will not be accessible via the browser, and only with our server, by using, for example, the fetch
functions.
// api/auth.js
import supabase from '../../supabase';
export async function post(req, res) {
if (req.method == 'POST') {
const { email, password, remember, authType } = req.body;
let userData;
switch (authType) {
case 'signin':
userData = await supabase.auth.api.signInWithEmail(email, password);
break;
case 'signup':
userData = await supabase.auth.api.signUpWithEmail(email, password);
break;
}
const { data, error } = userData;
if (error) {
console.log('Error signing in: ', error.name, error.message);
return res.end(JSON.stringify({ success: false }));
}
if (data) {
const tokenExpires = remember ? new Date(Date.now() + data.expires_in * 1000) : 0;
const refreshExpires = remember ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : 0;
const cookieOptions = { httpOnly: true, secure: false, sameSite: 'strict' };
res.cookie('supaToken', data.access_token, { expires: tokenExpires, ...cookieOptions });
res.cookie('supaRefresh', data.refresh_token, { expires: refreshExpires, path: '/api/refresh', ...cookieOptions });
if (remember) {
res.cookie('supaRemember', 1, { expires: refreshExpires, ...cookieOptions });
}
return res.end(JSON.stringify({ success: true }));
}
return res.end(JSON.stringify({ success: false }));
}
}
In our index.svelte
, we can then hook up our forms to a function that will call this API endpoint and redirect us to the dashboard if it works.
<script>
async function authenticate(event, authType) {
const { email, password, remember } = event.target.elements;
const authAPI = await fetch('/api/auth', {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
email: email.value,
password: password.value,
remember: remember.checked,
authType: authType
}),
headers: {
'Content-Type': 'application/json'
}
});
const { success } = await authAPI.json();
if (success) {
window.location.href = '/dashboard';
}
}
</script>
<!-- Everything here stays the same -->
<section>
<h1>Sign in</h1>
<form on:submit|preventDefault={ (e) => authenticate(e, 'signin') } >
<input id="email" type="email" placeholder="Email address">
<input id="password" type="password" placeholder="Password">
<label for="remember">
<input id="remember" type="checkbox">
Remember me
</label>
<button type="submit">Sign in</button>
</form>
</section>
<hr>
<section>
<h1>Sign up</h1>
<form on:submit|preventDefault={ (e) => authenticate(e, 'signup') } >
<input id="email" type="email" placeholder="Email address">
<input id="password" type="password" placeholder="Password">
<label for="remember">
<input id="remember" type="checkbox">
Remember me
</label>
<button type="submit">Sign up</button>
</form>
</section>
We use on:submit|preventDefault
as it, well, prevents the page from refreshing on submit of the file.
The last part here is to implement a way for the session to know when the user is authenticated and also a way for the server to understand JSON, as that is what we send when we call the API. We can do that in the server.js
file.
// server.js
import sirv from 'sirv';
import express from 'express';
import compression from 'compression';
import * as sapper from '@sapper/server';
import cookieParser from 'cookie-parser';
import { json } from 'body-parser';
import supabase from './supabase';
const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';
express()
.use(cookieParser())
.use(json())
.use(
compression({ threshold: 0 }),
sirv('static', { dev }),
sapper.middleware({
session: async (req, res) => {
// If there's a JWT, check if it's valid
const results = await supabase.auth.api.getUser(req.cookies['supaToken']);
if (results.user) {
return { userToken: req.cookies['supaToken'] };
}
return { userToken: false };
}
})
)
.listen(PORT, err => {
if (err) console.log('error', err);
});
For the The The Explanation
For the Supabase client, it's really simple. Using our project settings, we use Supabase's client library to create ourselves a client, that we can then use anywhere in our code to connect to our project.
auth.js
file, things get a bit more busy. First of all, we check if the request was really made using POST
. If it was, we extract the values we need to authenticate from req.body
, which we can use to check what type of authentication we are processing. Since the sign up and sign in functions both return the same data, we can use the same code to process them! Once we get the data, we check if there's any error, and if there is, we send the response back to the file that requested it. If there isn't any error, we check for data, which we then process a bit. First, we need to set the expiry times for the cookies we'll create. Using a ternary operator, we verify if the user checked the Remember me box. If they did, we set the expiry time of the access token to 1 hour, and the refresh token's to 30 days. If they didn't we set it to zero, meaning it'll get erased when the browser closes. The cookie options we created basically mean that our cookie can only be accessed by the web server (httpOnly
) and not JavaScript, that it can be accessed on non-HTTPS websites (be sure to change secure
to true
on production websites) and that it can only be accessed if the site for the cookie matches the site currently shown in the browser's URL bar. We then send the response back to the page, with the new cookies and the success
set to true
.index.svelte
page executes the authenticate()
function when either of the form buttons are pressed. It gets the event
from the form and sets a custom parameter called authType
that gets used by our API. It then fetches the result, with a JSON payload containing the entered email and password, the Remember me preference and the authType
. If the response is a success, it redirects the user to the dashboard.server.js
file got some additions too. First of all, we needed to import our new Supabase client, as well as the json()
function from body-parser
, and cookie-parser
. The json()
function enables us to read the req.body
correctly. Instead of automatically setting the userToken
to false
, we can now check if the token in the cookie (thanks to cookie-parser
) is valid using Supabase's auth.api.getUser()
function. This function returns a user property if the passed token is valid. If the cookie is valid, we put the token in the session.
You can now try authenticating and you should be redirected to the dashboard. You should also be able to see the cookies in your developer tools.
Step 5: Sign out function
We will now create our the signout
API endpoint. In the api
folder, create a file named signout.js
. This file, like the others, will not be accessible via the browser, and only with our server. The contents of this file will be similar to the other one.
// api/signout.js
import supabase from '../../supabase';
export async function post(req, res) {
if (req.method == 'POST') {
const { supaToken } = req.cookies;
const { error } = await supabase.auth.api.signOut(supaToken);
if (error) {
console.log('Error signing out: ', error.name, error.message);
return res.end(JSON.stringify({ success: false }));
}
res.clearCookie('supaToken');
res.clearCookie('supaRefresh', { path: '/api/refresh' });
res.clearCookie('supaRemember');
return res.end(JSON.stringify({ success: true }));
}
}
And now for our dashboard page.
<script context="module">
export async function preload(page, session) {
let { userToken } = session;
if (!userToken) return this.redirect(302, '/');
return userToken;
}
</script>
<script>
async function signOut() {
const authAPI = await fetch('/api/signout', {
method: 'POST',
credentials: 'same-origin',
});
const { success } = await authAPI.json();
if (success) {
window.location.href = '/';
}
}
</script>
<!-- Everything here stays the same -->
<h1>Our very cool dashboard</h1>
<button on:click="{ signOut }">Sign out</button>
In our dashboard, the Explanation
For the signout
endpoint, we get the supaToken
from the request's cookies. Then, we sign out using Supabase's included methods. If all goes well, we clear all of the cookies (specifying the path is needed in order to clear the cookie) and we return success
as true
.
signOut()
function is quite similar to the authenticate()
in our login page. The main difference is that it uses our signout
endpoint, and it doesn't require a body, as it gets the token from the cookies.
Step 6: Keeping the user signed in
Now our users can log in, which is great, but the access token (which is what we use to detect if the user is logged in) expires after 1 hour, which is gonna be a problem if our users either stay on the website for more than 1 hour or if they leave the website and then come back.
This step is a bit more complex than the others, or at least it felt like it when I was figuring it out. This method will refresh the access token every 55 minutes, making sure it never expires, and if the user accesses the page with an expired token, it will also try to refresh it if possible. However, the only issue that I haven't figured out yet is how to make it work on protected pages correctly. So if somebody figures it out, I'd like to know how you did it!
To begin this, we will create yet another API endpoint, called refresh.js
. This file will contain this code:
// api/refresh.js
import supabase from '../../supabase';
export async function post(req, res) {
if (req.method == 'POST' && req.cookies['supaRefresh']) {
const refreshToken = req.cookies['supaRefresh'];
const remember = req.cookies['supaRemember'];
const { data, error } = await supabase.auth.api.refreshAccessToken(refreshToken);
if (error) {
console.log('Error refreshing token: ', error.name, error.message);
return res.end(JSON.stringify({ supaToken: false }));
}
if (data) {
const tokenExpires = remember ? new Date(Date.now() + data.expires_in * 1000) : 0;
const refreshExpires = remember ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : 0;
const cookieOptions = { httpOnly: true, secure: false, sameSite: 'strict' };
res.cookie('supaToken', data.access_token, { expires: tokenExpires, ...cookieOptions });
res.cookie('supaRefresh', data.refresh_token, { expires: refreshExpires, path: '/api/refresh', ...cookieOptions });
if (remember) {
res.cookie('supaRemember', 1, { expires: refreshExpires, ...cookieOptions });
}
return res.end(JSON.stringify({ supaToken: data.access_token }));
}
return res.end(JSON.stringify({ supaToken: false }));
}
}
Now, since the code is extremely similar to our authentication code, we could just use the same endpoint, but I prefer keeping it separated due to the refresh token being a bit more sensitive info.
This code will be called in _layout.svelte
as the functions in this file are executed on every page.
<!-- _layout.svelte -->
<script>
import { onMount } from 'svelte';
import { stores } from '@sapper/app'
const { session } = stores()
import Nav from '../components/Nav.svelte';
export let segment;
onMount(async () => {
if (!$session.userToken) {
const refresh = await fetch(`/api/refresh`, {
method: 'POST',
credentials: 'same-origin'
});
const { supaToken } = await refresh.json();
if (supaToken) {
return session.set({ userToken: supaToken });
}
return;
}
// refreshes token every 55 minutes to also sync with server-side.
setInterval(async () => {
const refresh = await fetch(`/api/refresh`, {
method: 'POST',
credentials: 'same-origin'
});
const { supaToken } = await refresh.json();
if (supaToken) {
return session.set({ userToken: supaToken });
}
console.log('No user, timeout will be killed eventually')
}, 1000 * 60 * 55);
});
</script>
Our Explanation
The refresh
endpoint is basically identical to our auth
endpoint, so I will not be explaining it in details. The main difference here is that we return the userToken
instead of the success
.
_layout.svelte
code is a bit more complex however. It uses Svelte's onMount()
method to execute the code once the components appears in the DOM. It also gets the session from the Svelte's stores
. In the onMount()
, we check if the userToken exists. If it doesn't we try to refresh it using our new endpoint. We then use the returned token to update our session with the new, valid, access token. The cookies have already been set by the refresh
endpoint, so they're in sync. The interval at the bottom makes sure that a new access token gets generated every 55 minutes, so that it never invalidates, preventing our users from getting errors.
We're done 🎉
And that's it! We are officially done with this and everything should work well! We now have a secure authentication flow that doesn't use client-side code using Sapper and Supabase. Stay tuned, as I will definitely make more tutorials using these 2 technologies soon(™).
If you want to check out my projects, you can go to my portfolio or my GitHub. At the time of writing, my portfolio is getting remade from scratch as I don't like my current one.
* Supabase is free for now, but it will become paid in the future. However, if you sign up during the beta you get free credits!
If you have any questions or just want to say hello, leave a comment and I’ll be happy to reply! And obviously, if I missed something, tell me, I will credit you for the fix!
Top comments (5)
hi and thanks for the tutorial, I'll try today. It's ages I try to grok authentication "done right" but I always have some fear about security, especially about XSS and CSRF stuff... So sorry for the dumb question, but do you know if your implementation is "safe" from this point of view?
love it!! thanks for sharing, Jakob
Great post !
super helpful, thanks for sharing
very very very very interesting! :_)