DEV Community

Jakob Bouchard
Jakob Bouchard

Posted on

How to setup Supabase Auth with Sapper SSR

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

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

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);
    });

Enter fullscreen mode Exit fullscreen mode

You can now run npm run dev and open localhost:3000 in your browser. You should be greeted by this:

Sapper template homepage

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

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

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

Our pages should now look like this:
Authentication page
Dashboard

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

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

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:

Supabase API Settings

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

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 }));

    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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.

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

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

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

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

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.

In our dashboard, the 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 }));
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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.

Our _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!

Discussion (5)

Collapse
pdina profile image
Paolo E Basta

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?

Collapse
awalias profile image
awalias

love it!! thanks for sharing, Jakob

Collapse
souksyp profile image
Souk Syp.

Great post !

Collapse
dayvista profile image
dayvista

super helpful, thanks for sharing

Collapse
codicezero profile image
codicezero

very very very very interesting! :_)