DEV Community

Cover image for Supabase SSR Auth with SvelteKit
kvetoslavnovak
kvetoslavnovak

Posted on • Updated on

Supabase SSR Auth with SvelteKit

EDIT: This tutorial was originaly released for @supabase/supabase-auth-helpers package and later rewritten for @supabase/ssr package beta version. Supabase is still making some significant changes in SSR package as heading towards 1.0 release. You can follow updates here. I am trying to keep it as uptodate as possible. This tutorial is based on @supabase/ssr: 0.4.0, @supabase/supabase-js: 2.44.2 and @sveltejs/kit: 2.0.0.

N.B.: There is an infamous warnning log that Supabase added. This tutorial avodis it as much as possible, but even standard internal Supabase methods such as auth.updateUser fire this warning log. Hoping Supabase will solve this soon.

Supabase @supabase/ssr package

Supabase recently introduced a @supabase/ssr package instead of their @supabase/supabase-auth-helpers package. Supabase generally recommends using the new @supabase/ssr package which takes the core concepts of the Auth Helpers package and makes them available to any server framework. The Supabase Auth Helpers will be probably deprecated later on as this package is not maintained anymore.

@supabase/ssr package is ment to be used easily with any framework wich includes backend such as Next.js, SvelteKit, Astro, Remix or Express. This was the main idea of Supabase to have just one general easier to maintain package which was not the case for Auth Helpers.

This tutorial walks you through the process how to use @supabase/ssr package with Sveltekit. The implementation is very easy, smooth and rather straightforward.

Create SvelteKit Project

Create the SvelteKit app and name it for example "my-sk-app-with-sb-ssr-auth".

npm create svelte@latest my-sk-app-with-sb-ssr-auth
cd my-sk-app-with-sb-ssr-auth
npm install
Enter fullscreen mode Exit fullscreen mode

Now install relevant Supabase packages:

npm install @supabase/ssr @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Create Supabase project

If you do not have your Supabase project create the new one. Just follow the instructions on https://supabase.com/ and start the new project. From your Project Settings dashboard in section API details copy SUPABASE_URL and SUPABASE_ANON_KEY keys which are to be used in a front end of your application.

SUPABASE_URL and SUPABASE_ANON_KEY keys

Public Variables

Create a .env.local file in your SvelteKit project root directory. Use your SUPABASE_URL and SUPABASE_ANON_KEY keys whcih you have just copied from your Supabase project's dashboard.

# .env.local
PUBLIC_SUPABASE_URL=your_supabase_project_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
Enter fullscreen mode Exit fullscreen mode

Creating Supabase createServerClient in Hooks

In your SvelteKit project root directory create a hooks.server.js file. In this file we are settig up Supabase server client using imported env keys. Creating a Supabase client with the ssr package automatically configures it to use Cookies.

You might be currious why we are repopulating the user in the session received via supabase.auth.getSession() wtih the user from supabase.auth.getUser(). This is to avoid infamous warnings that Supabase logs if you are using the user object in your application as returned from supabase.auth.getSession(). When Supabase warning logs will be solved there will be no need for this session.user hack.

You may even silence the warning but this is rather a heck.

// hooks.server.js
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
import { createServerClient } from '@supabase/ssr'

export const handle = async ({ event, resolve }) => {
  event.locals.supabaseServerClient = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
      cookies: {
          getAll() {
              return event.cookies.getAll()
          },
          setAll(cookiesToSet) {
              cookiesToSet.forEach(({ name, value, options }) =>
                  event.cookies.set(name, value, { ...options,path: '/' })
              )
          },
      },
  })

// if you want to silence the warnings https://github.com/supabase/auth-js/issues/873
  if ('suppressGetSessionWarning' in event.locals.supabaseServerClient.auth) {
    // @ts-expect-error - suppressGetSessionWarning is not part of the official API
    event.locals.supabaseServerClient.auth.suppressGetSessionWarning = true;
  } else {
    console.warn(
      'SupabaseAuthClient#suppressGetSessionWarning was removed. See https://github.com/supabase/auth-js/issues/888.',
    );
  }

  const getSessionAndUser = async () => {
      const { data: { session } } = await event.locals.supabaseServerClient.auth.getSession()
      if (!session) {
          return {
              session: null,
              user: null
          }
      }

      const { data: { user }, error } = await event.locals.supabaseServerClient.auth.getUser()
      if (error) {
          // JWT validation has failed
          return {
              session: null,
              user: null
          }
      }

      delete session.user
      const sessionWithUserFromUser = { ...session, user: {...user} }

      return { session: sessionWithUserFromUser, user }
  }

  const { session, user } = await getSessionAndUser()

  event.locals.session = session
  event.locals.user = user

  return resolve(event, {
      filterSerializedResponseHeaders(name) {
          return name === 'content-range' || name === 'x-supabase-api-version'
      },
  })
}
Enter fullscreen mode Exit fullscreen mode

Returning Session from Root Server Layout

Create +layout.server.js file in the routes directory. This file will just pass the session with a respective user.

// src/routes/+layout.server.js
export const load = async (event) => {
// clearing the cookie from a browser if the user logs out or was deleted from the database
  if (event.locals.session == null) {
  event.cookies.delete(event.locals.supabaseServerClient.storageKey, { path: '/' });
}

  return {
      session: event.locals.session,
      user: event.locals.user
  };
};
Enter fullscreen mode Exit fullscreen mode

Creating Supabase createBrowserClient in Root Layout Load

Now we will create +layout.js file in a routes directory. In this file we will set up Supabase browser client. Page components can get access to the Supabase client from the data object due to this load function.

Note that you may not need the Supabase browser client if you do all the stuff server side.

The very important part of the code is to use depends('supabase:auth'). 'supabase:auth' is just an identifier, you can name it however you want. If later on we will need to reload this load function (particularly when user's auth state changes) we can do so using invalidate function after such a change with this identifier 'supabase:auth' as an argument (i.e. invalidate('supabase:auth')). You will see us using this feature in src/routes/+layout.svelte later on.

// src/routes/+layout.js
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'

export const load = async ({ fetch, data, depends }) => {
  depends('supabase:auth')

  const supabase = isBrowser()
    ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
        global: {
          fetch,
        },
      })
    : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
        global: {
          fetch,
        },
        cookies: {
          getAll() {
            return data.cookies
          },
        },
      })

      const session = isBrowser()
      ? (await supabase.auth.getSession()).data.session 
      : data.session

  return {
    supabase,
    session,
    user: data.user
  }
}
Enter fullscreen mode Exit fullscreen mode

Routes Layout Page

It seems as a good practice to use SvelteKit layout page as a login/logout header navigation. I have added just a little bit of css to move loginn/logout nav to the top right corner.

The important stuff is to add onMout and listen to the onAuthStateChange of Supabase (mainly to listen to the events when user logs in or out).

If such changes happen we will invalidate all relevant load functions.

One way is to put into such load function depends function. In this depends function we may specify on which invalidation it dependes. So in our case it depends on "supabase:auth" (i.e. depends('supabase:auth'). We have already done so in load function in a root layout.js file as you can see above. Then in svelte file where we need invalidation in matter to run we can call named invalidation (i.e. invalidate('supabase:auth')). You should carefully decide what is linked to what. Root +layout.js and +layout.svelte are probably quite top level enough.

The other more general and bulletproof attitude is to use invalidateAll(). You put it in svelte file when some change may happen and thus reload all load functions of your application. Without bothering to guess to which load function you should add that it depends on such a change.

As auth is quite sensitive I would rather recommend to call more general invalidateAll() function instead, just to be sure all load functions are rerun.

In example code bellow I am evem using both invalidations.

In any case invalidation will sync server Supabase client and the browser Supabase client as the load function(s) will rerun. So you will update/sync the session state as well all browser tabs where you may run the app.

The layout has the logout form for logged in user as well and calls the repsective logout route/action. The relevant endpoint in /logout route is descibed in a secton Route with Logout Logic.

// src/routes/+layout.svelte
<script>
    import { enhance } from '$app/forms';
    import { invalidate, invalidateAll, goto } from '$app/navigation';
    import { onMount } from 'svelte';

    export let data;

    $: ({ supabase } = data);

    onMount(async () => {
        const {
            data: { subscription }
        } = supabase.auth.onAuthStateChange((event, _session) => {
// If you want to fain grain which routes should rerun their load function 
// when onAuthStateChange changges
// use invalidate('supabase:auth') 
// which is linked to +layout.js depends('supabase:auth'). 
// This should mainly concern all routes 
//that should be accesible only for logged in user. 
// Otherwise use invalidateAll() 
// which will rerun every load function of you app.
            invalidate('supabase:auth');
            invalidateAll();
        });
        return () => subscription.unsubscribe();
    });

    const submitLogout = async ({ cancel }) => {
        const { error } = await data.supabase.auth.signOut();
        if (error) {
            console.log(error);
        }
        cancel();
        await goto('/');
    };
</script>

<a href="/">Home</a>
<a href="/subscription">Subscriptions</a>

<span id="auth_header">
    {#if !data.session}
        <a href="/login">login</a> / <a href="/register">signup</a>
    {:else}
        <a href="/user_profile">User profile</a>
        <form action="/logout?/logout" method="POST" use:enhance={submitLogout}>
            <button type="submit">Logout</button>
        </form>
    {/if}
</span>
<slot />

<style>
    #auth_header {
        float: right;
    }
    form {
        display: inline;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Email Auth with PKCE flow for SSR

We will use email authentication. In order to use the updated email links we will need to setup an endpoint for verifying the token_hash along with the type to exchange token_hash for the user's session which is set as a cookie for future requests made to Supabase. This endpoint will be used mainly for auth emails connfirmations.

Create a new file at src/routes/auth/confirm/+server.js and populate with the following:

// src/routes/auth/confirm/+server.js
import { redirect } from '@sveltejs/kit';

export const GET = async (event) => {
    const {
        url,
        locals: { supabaseServerClient }
    } = event;
    const token_hash = url.searchParams.get('token_hash');
    const type = url.searchParams.get('type');
    const next = url.searchParams.get('next') ?? '/';

  if (token_hash && type) {
    const { error } = await supabaseServerClient.auth.verifyOtp({ token_hash, type });
    if (!error) {
      redirect(303, `/${next.slice(1)}`);
    }
  }

  // return the user to an error page with some instructions
  redirect(303, '/auth/auth-code-error');
};
Enter fullscreen mode Exit fullscreen mode

And for convenience we may provide the herebaove mentioned auth-code-error error page.

// src/routes/auth/auth-code-error/+page.svelte
There was some logging error. 
Enter fullscreen mode Exit fullscreen mode

Simple Home Page

Create simple home lannding page.

// src/routes/+page.svelte
<h1>  Welcome to this website ...</h1>
Enter fullscreen mode Exit fullscreen mode

Register Route

Finally we will do some logging logic. First of all we will create register page as well as relevant page server file. Notice that we are also conditionaly dsiplaying error message in case the form submission was invalid and errored with 4xx client errors HTTP response status.

// src/routes/register/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form;
</script>

<h2>Sign Up</h2>
<form action="?/register" method="POST" use:enhance>
    <label for="email">email</label>
    <input name="email" type="email" value={form?.email ?? ''} required />
    <label for="password">password</label>
    <input name="password" required />
    <button type="submit">Sign up</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

And the corresponding +page.server.js file logic is as follows. In a nutshell here we are just calling supabase.auth.signUp() providing it with the email and password, handling possible errors and redirecting user eventually to check email page.

// src/routes/register/+page.server.js
import { fail, redirect } from "@sveltejs/kit"
import { AuthApiError } from '@supabase/supabase-js'

export const actions = {
    register: async (event) => {
        const { request, locals } = event
            const formData = await request.formData()
            const email = formData.get('email')
            const password = formData.get('password')

            const { data, error: err } = await locals.supabaseServerClient.auth.signUp({
                email: email,
                password: password
            })

            if (err) {
                if (err instanceof AuthApiError && err.status >= 400 && err.status < 500) {
                    return fail(400, {
                        error: "invalidCredentials", email: email, invalid: true, message: err.message
                    })
                }
                return fail(500, {
                    error: "Server error. Please try again later.",
                })
            }
            // signup for existing user returns an obfuscated/fake user object without identities https://supabase.com/docs/reference/javascript/auth-signup
            if (!err && !!data.user && !data.user.identities.length ) {
                return fail(409, {
                    error: "User already exists", email: email, invalid: true, message: "User already exists"
                })
            }
            redirect(303, "/check_email");
    }
}


  export const load = async ({ locals }) => {
    // if the user is already logged in redirect to the home page 
    if (locals.session) {
        redirect(303, '/');
    }
}
Enter fullscreen mode Exit fullscreen mode

You also need to update Supabase auth email templates.

Supabase auth email templates

Go to your Supabase project dashboard website and in Authentication secition update Confirm signup email template like this.

<h2>Confirm your signup</h2>

<p>Follow this link to confirm your user:</p>
<p>
  <a href="http://localhost:5173/auth/confirm?token_hash={{ .TokenHash }}&type=email"
    >Confirm your email</a>
</p>
Enter fullscreen mode Exit fullscreen mode

N.B. Do not forget to change "http://localhost:5173" to your app website address in all Supabase email templates when you host your app eventually.

Check Email Route

Create check_email route with simple +page.svelte file.

// src/routes/check_email/+page.svelte
<p>Check your email to confirm.</p>
Enter fullscreen mode Exit fullscreen mode

Route with Login Logic

Create login route which will enable user to login.

// src/routes/login/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form;
</script>

<h2>Log in</h2>
<form action="?/login" method="POST" use:enhance>
    <label for="email">email</label>
    <input name="email" type="email" value={form?.email ?? ''} required />
    <label for="password">password</label>
    <input name="password" required />
    <button type="submit">Login</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}

<p>Forgot your password? <a href="/reset_password">Reset password</a></p>
Enter fullscreen mode Exit fullscreen mode

The respective +page.server.js file contains action for login. Simply speaking in case of login we are just calling supabase.auth.signInWithPassword() providing it with email and password, handling possible errors and redirecting user eventually.

// src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { AuthApiError } from '@supabase/supabase-js';

export const actions = {
    login: async (event) => {
        const { request, url, locals } = event;
        const formData = await request.formData();
        const email = formData.get('email');
        const password = formData.get('password');

        const { data, error: err } = await locals.supabaseServerClient.auth.signInWithPassword({
            email: email,
            password: password
        });

        if (err) {
            if (err instanceof AuthApiError && err.status === 400) {
                return fail(400, {
                    error: 'Invalid credentials',
                    email: email,
                    invalid: true,
                    message: err.message
                });
            }
            return fail(500, {
                message: 'Server error. Try again later.'
            });
        }

        redirect(307, '/');
    },
}

 export const load = async ({ locals }) => {
    // if there is a user's session redirect back to the home page
    if (locals.session) {
        redirect(303, '/');
    }
}
Enter fullscreen mode Exit fullscreen mode

Route with Logout Logic

In case of logout we are calling supabase.auth.signOut(), which also automatically deletes user cookie, and redirects to home page. There is just the +page.server.js file, this is why I am adding uncoditional redirect and no +page.svelte file. The client form for logout is already in +layout.svelte as mentioned hereabove.

// src/routes/logout/+page.server.js
import { redirect } from '@sveltejs/kit';

export const actions = {
    logout: async ({ locals }) => {
        await locals.supabaseServerClient.auth.signOut()    
        redirect(303, '/');
    }
}

// no one should visit this page
export async function load() {
        redirect(303, '/');
}
Enter fullscreen mode Exit fullscreen mode

Reset Password

Make route for password reset called reset_password, Once again one +page.svelte file and one +page.server.js file.

// src/routes/reset_password/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Where should we send you a link for password reset?</h2>
<form action="?/reset_password" method="POST" use:enhance>
    <label for="email">email</label>
    <input type="email" name="email" placeholder="name@domain.com" required />
    <button type="submit">Get password</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

In a nutshell in reset_password/+page.server.js we are just calling supabase.auth.resetPasswordForEmail() providing it with email and route of a password update page, handling possible errors and redirecting user eventually.

// src/routes/reset_password/+page.server.js
import { fail, redirect } from "@sveltejs/kit"
import { AuthApiError } from "@supabase/supabase-js"

export const actions = {
    reset_password: async ({ request, locals }) => {
        const formData = await request.formData()
        const email = formData.get('email')

        const { data, error: err } = await locals.supabaseServerClient.auth.resetPasswordForEmail(
            email, 
            {redirectTo: '/update_password'}
        )

        if (err) {
            if (err instanceof AuthApiError && err.status === 400) {
                return fail(400, {
                    error: "invalidCredentials", email: email, invalid: true, message: err.message
                })
            }
            return fail(500, {
                error: "Server error. Please try again later.",
            })
        }

        redirect(303, "/check_email");
    },
}

export const load = async ({ locals }) => {
    // if the user is already logged in redirect to the home page 
    if (locals.session) {
        redirect(303, '/');
    }
}
Enter fullscreen mode Exit fullscreen mode

The Supabase email template for Reset password looks like this. The link will send the user to /update_password of our application.

<h2>Reset Password</h2>

<p>Follow this link to reset the password for your user:</p>
<p>
  <a
    href="http://localhost:5173/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/update_password"
    >Reset Password</a
  >
</p>
Enter fullscreen mode Exit fullscreen mode

Update Password Route

As already mentioned resetting password needs the route update_password where the user may insert her/his new password. Lets create this update_password route.

// src/routes/update_password/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Change your password</h2>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
<form action="?/update_password" method="POST" use:enhance>
    <label for="new_password"> New password </label>
    <input name="new_password" required/>   
    <label for="password_confirm">Confirm new password</label>
    <input name="password_confirm" required/>       
    <button>Update password</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Simply speaking here we are just calling supabase.auth.updateUser() providing it with new password, handling possible errors and redirecting user eventually to her/his profile page.

// src/routes/update_password/+page.server.js
import { AuthApiError } from "@supabase/supabase-js"
import { fail, redirect } from "@sveltejs/kit"

export const actions = {
    update_password: async ({ request, locals }) => {
        const formData = await request.formData()
        const password = formData.get('new_password')

        const { data, error: err } = await locals.supabaseServerClient.auth.updateUser({
            password
        })

        if (err) {
            if (err instanceof AuthApiError && err.status >= 400 && err.status < 500) {
                return fail(400, {
                    error: "invalidCredentials", invalid: true, message: err.message
                })
            }
            return fail(500, {
                error: "Server error. Please try again later.",
            })
        }

        redirect(303, "/user_profile");
    },
}

export const load = async ({ locals }) => {
    // if there is no user's session redirect back to the home page
    if (!locals.session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

Update Email Route

User may wish to update her/his emial so here is the update_email route to do this. Remeber the confirmaton from both emails (the old one as well as the new one) has to be provided.

// src/routes/update_email/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form;
</script>

<h2>Change your email</h2>
<form action="?/update_email" method="POST" use:enhance>
    <label for="email"> new email </label>
    <input type="email" name="email" required />
    <button>Change email</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

In a nutshell here we are just calling supabase.auth.updateUser() this time providing it with the new email, handling possible errors and redirecting user to her/his profile page eventually.

// src/routes/update_email/+page.server.js
import { AuthApiError } from "@supabase/supabase-js"
import { fail, redirect } from "@sveltejs/kit"

export const actions = {
    update_email: async ({ request, locals }) => {
        const formData = await request.formData()
        const email = formData.get('email')

        const { data, error: err } = await locals.supabaseServerClient.auth.updateUser({
            email
         })

          if (err) {
            if (err instanceof AuthApiError && err.status >= 400 && err.status < 500) {
                return fail(400, {
                    error: "invalidCredentials", invalid: true, message: err.message
                })
            }
            return fail(500, {
                error: "Server error. Please try again later.",
            })
        }

        redirect(303, "/check_email");
    },
}

export const load = async ({ locals }) => {
    // if there is no user's session redirect back to the home page
    if (!locals.session) {
        redirect(303, '/');
    }
}
Enter fullscreen mode Exit fullscreen mode

And the Supabase email template for Change Email Address looks like this.

<h2>Confirm Change of Email</h2>

<p>Follow this link to confirm the update of your email from {{ .Email }} to {{ .NewEmail }}:</p>
<p>
  <a href="http://localhost:5173/auth/confirm?token_hash={{ .TokenHash }}&type=email_change">
    Change Email
  </a>
</p>
Enter fullscreen mode Exit fullscreen mode

User Profile Route

We are still missing user profile route where user can manage the account. Let create user_profile route with respective files.

// src/routes/user_profile/+page.svelte
<script>
    export let data;
</script>

<h2>User profile</h2>
<p>{data.session.user.email}</p>
<p><a href="/update_email">Change your email</a></p>
<p><a href="/update_password">Change password</a></p>
<p><a href="/delete_user">Delete my account</a></p>
Enter fullscreen mode Exit fullscreen mode

The page should be accesible only to logged in user I guess.

// src/routes/user_profile/+page.server.js
import { redirect } from "@sveltejs/kit"

export const load = async ({ locals }) => {
    // redirect if there is no user's session
    if (!locals.session) {
        redirect(303, '/');
    }

    return {
        session: locals.session,
        user: locals.user
    }
}
Enter fullscreen mode Exit fullscreen mode

Delete User Account Route

The opinions may differ wheter we should enable user to delete her/his account. But as this may seem tricky in Supabase here is the way. Lets make delete_user route.

// src/routes/delete_user/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Delete your user account</h2>
<form action="?/delete_user" method="POST" use:enhance>
    <button type="submit">Delete my user account</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

Supbase uses special auth client created with secrete service role key to delete user. But the client created with the service role key has really high superuser/admin priviledges. If you do not want to deal with this mighty key here is a trick.

In Supabase dashboard go to SQL Editor.

Supabase SQL Editor

In the SQL Editor paste in and run this function.

    CREATE or replace function delete_user()
    returns void
    LANGUAGE SQL SECURITY DEFINER
    AS $$
    --delete from public.profiles where id = auth.uid();
    delete from auth.users where id = auth.uid();
    $$;
Enter fullscreen mode Exit fullscreen mode

Now you can use this Supabase database delete_user() function from server using supabase.rpc method with the name of this delete_user database function as an argument like this.

// src/routes/delete_user/+page.server.js
import { PRIVATE_RPC_DELETE_USER } from '$env/static/private'
import { redirect } from "@sveltejs/kit"

export const actions = {
    delete_user: async ({ locals, request,  cookies }) => {
    const storageKey = locals.supabaseServerClient.storageKey
    await locals.supabaseServerClient.rpc('delete_user');
    cookies.delete(storageKey, { path: '/' });
    redirect(303, "/");
    }
}

  export const load = async ({ locals }) => {
    // if there is no user's session redirect back to the home page
    if (!locals.session) {
        redirect(303, '/');
    }
}
Enter fullscreen mode Exit fullscreen mode

It is also important to delete user cookie. The name of the cookie can be found in supabase.storageKey. Because of this cookie deletion application kicks user out from all pages where session is requested.

Project Structure Overview

Here goes project structure of all the +page.svelte and +page.server.js files tree printscreen. (There are also routes for Stripe subscription so don't get confused)

SvelteKit Supabase SSR project structure

Thank You for Reading

So this is it. Feel free to comment if something does not work for you. And if you find this tutorial useful at least a little please give it a like.

You may move protected routes logic to one place (probably into hooks) for example not to repeate it in relevant +page.server.js files.

I hope to post something soon, been busy this year with a SvelteKit project but now it is nearly done so more time for blogging.

EDIT December 11, 2023:
Added usecase for signup/registration of already exising user.
Added usecase to clear cookie if the user was deleted in the database.

EDIT December 12, 2023:
As some of you have noticed there was addUserprofileToUser() helper function. I deleted this part of the code to avoid any confusion. This addUserprofileToUser() utility function is used to enrich user data in her/his session. The reason is that Supabase does not allow you to add any data to Authetication table. For that you have to create your new table (for example "user_profile" table with its primary key (id) referencing Authetication table id).

My addUserprofileToUser() function looks like this:

export default async function addUserprofileToUser (session, supabase, user) {
    if (session) {
      let { data, error } = await supabase
        .from('user_profile')
        .select("*")
        .eq('id', user.id)
        .single()
      user.user_profile = data
    }
  }
Enter fullscreen mode Exit fullscreen mode

The hooks.server.js with addUserprofileToUser will look like this.

import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
import { createServerClient } from '@supabase/ssr'
import addUserprofileToUser from './utils/addUserprofileToUser'

...

      delete session.user
      await addUserprofileToUser(session, event.locals.supabaseServerClient, user)
      const sessionWithUserFromUser = { ...session, user: {...user} }

      return { session: sessionWithUserFromUser, user }
...
Enter fullscreen mode Exit fullscreen mode

The addUserprofileToUser function simply gets the data from the user_profile table for the particular user and enriches her/his session. I call this function await addUserprofileToUser(session,event.locals.supabase) in hooks.server.js.

In the profile table there is for example Stripe id of the user, her/his subpscription plan etc. But this Stripe SaaS features would need the whole new tutorial :-) Maybe next time.

EDIT February 7, 2024:
Update to help migrating to SvelteKit v2.

EDIT February 11, 2024:
I have updated the tutorial to fully implement invalidate('supabase:auth');. Added separate logout route as well. So client part of the applicatoin refreshes relevant load functions correctly when needed and all is in sync including app opened in more browser tabs.

EDIT July 9, 2024:
Update because of Supabase SSR package update.

Top comments (34)

Collapse
 
maxkozlowski profile image
Maksymilian Kozlowski

Awesome article, thank you!

Have you tried doing OAuth? Any chance you could add this to the article too?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Thank you for your interest.
I definitely plan to look into OAuth as well.

Collapse
 
uw profile image
uwork

Something I noticed when duplicating a logged-in session on 2 browser tabs. If I log out on the second tab and click around on the first tab, the behavior is 'interesting'.

  • I observed the cookie is cleared so the user session is gone as expected in both tabs
  • On the first tab: If I refresh the page from the profile page or one of its sub-pages, it redirects back to the home page and updates the nav bar properly as expected.
  • On the first tab: if I am on the profile page and click a sub-page (eg: change password) the body of the page is updated (redirected to home) but the nav bar is not updated and still shows the 'Logout' button. Any idea what's causing this difference in behavior?
Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
uw profile image
uwork

I am still seeing the same behavior on the browser (logout button is still visible). I added a console.log on onMount and noticed it doesn't get printed when following this sequence of actions.

Not sure if related, I am using TS, and the IDE warns me on the line onMount(async () => {
that:
Svelte: Argument of type  () => Promise<() => void>  is not assignable to parameter of type  () => (() => any) | Promise<never> 

Thread Thread
 
Sloan, the sloth mascot
Comment deleted
 
Sloan, the sloth mascot
Comment deleted
 
uw profile image
uwork

Thank you for your efforts. I'll take a look!

Thread Thread
 
Sloan, the sloth mascot
Comment deleted
 
uw profile image
uwork • Edited

Hey, this video explains this problem well and suggests a solution:
Hope it helps anyone else as well!

To reload the layout view after clicking on a navigation item do this:

<a href="/sub-page" class:text-muted-foreground={$page.url.pathname !== '/sub-page'}>your sub page</a>
Enter fullscreen mode Exit fullscreen mode

This appends a CSS class if the URL does not match the path name.

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

I have updated the tutorial. Now everything works correctly and in sync. The point was to properly implement invalidation listeninng to supabase.auth.onAuthStateChange.

Please test the app and let me know if it works for you as well.

Collapse
 
cropwatchdevelopment profile image
CropWatch

Let me first say, thank you so much for this awesome article!
I am however wondering about the warning I keep getting:

Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and many not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.

Any ideas how to remove this? All of my attempts have fallen flat.

Also, an OAuth article would be awesome!!!

Thank you!!!

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

Hi, thank you for your thank you :)

Concerning the warning I recommend to follow Supabase recently updated official documentation. I did not have time to update this tutorial. But I guess you should be good as long as you call supabase.auth methods with supabase.auth.getUser() to check JWT validation.

Try this event.locals.safeGetSession, see the new documentation here

import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
import { createServerClient } from '@supabase/ssr'
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
    cookies: {
      get: (key) => event.cookies.get(key),
      /**
       * Note: You have to add the `path` variable to the
       * set and remove method due to sveltekit's cookie API
       * requiring this to be set, setting the path to an empty string
       * will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
       */
      set: (key, value, options) => {
        event.cookies.set(key, value, { ...options, path: '/' })
      },
      remove: (key, options) => {
        event.cookies.delete(key, { ...options, path: '/' })
      },
    },
  })

  /**
   * Unlike `supabase.auth.getSession()`, which returns the session _without_
   * validating the JWT, this function also calls `getUser()` to validate the
   * JWT before returning the session.
   */
  event.locals.safeGetSession = async () => {
    const {
      data: { session },
    } = await event.locals.supabase.auth.getSession()
    if (!session) {
      return { session: null, user: null }
    }

    const {
      data: { user },
      error,
    } = await event.locals.supabase.auth.getUser()
    if (error) {
      // JWT validation has failed
      return { session: null, user: null }
    }

    return { session, user }
  }

  return resolve(event, {
    filterSerializedResponseHeaders(name) {
      return name === 'content-range' || name === 'x-supabase-api-version'
    },
  })
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
cropwatchdevelopment profile image
CropWatch

I Understand now and based on your example I was able to solve the issue. Again, THANK YOU!

Thread Thread
 
kvetoslavnovak profile image
kvetoslavnovak

I have just updated the tuutorial to refelect new ssr package changes. My code should also solve the warning issues.

Collapse
 
yournewempire profile image
Archie Smyth • Edited

Great article, you are much more talented and focused than I am. I love the way you avoid the "infamous warnings" , very elegant but that's me. My only question is can getUser return null but also no error ? One q. Should I check for !user also? Wonder if you know anything about this?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

Thank you very much. I still remember to be a total noob to programming. In my case it is like other skills, it takes time, focus and practice. But till today I do not consider myself to be talented.

Collapse
 
rx40 profile image
Petrus-Nauyoma

Hi mate.
Do you have a public GitHub repo?
Want to start a SaaS project from it.
This will support multi users having their own separate sessions once deployed right?
Tried to do the session validation in the middleware on dotnet with Supabase but all my users ended up sharing the same session.
So I don't want to have to rethink all that and just get started with protected routes from your Svelte+supabase starter

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

Hi, if you need session validation I recommend you this github.com/j4w8n/sveltekit-supabas... repo. For validation look into hooks.server.ts github.com/j4w8n/sveltekit-supabas...
file.
Supabase guys are working on introducing asymmetric JWTs so expect changes anyway.

Collapse
 
kennek profile image
Kennedy

Thanks again for the article. I am still not all the way there though. Everything works as expected in development but when I try to deploy on vercel, I get the error:

RollupError: "PUBLIC_SUPABASE_URL" is not exported by "virtual:$env/static/public", imported by "src/routes/+layout.ts".

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Hi, thank you for yout thank you.
Concernig Vercel did you set up all your environment varaibles in Vercel? vercel.com/docs/projects/environme...

Collapse
 
uw profile image
uwork

Awesome article, you have helped me so much as I am new to the JS world. Please do an OAuth one with Supabase+SK as well! Thanks!!

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Glad to hear you did like the tutorial.

Collapse
 
jandriw profile image
Alejandro

Thank you for this post kvetoslanovak!

Works perfectly! 😁😍

Collapse
 
dylanb101 profile image
dylanb-101

just started a new project and decide to use these two; great article!

but I was wondering what the addUserprofileToUser function did?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Thank you, I have added the explanaton as a second edit in the bottom of the article.

Collapse
 
asahelk profile image
Asahel

Great article! I'm new with sveltekit, Is there a form to avoid duplicate load function for session validation?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Hi, thank you.
I am not sure which duplicate load function do you mean? Could you be more specific, please?

Collapse
 
asahelk profile image
Asahel • Edited
export async function load({locals: { getSession }}) {
    const session = await getSession();
    // if the user is already logged in return him to the home page
    if (session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

Each +page.server.js is checking if user is already looged in. Is there a way to check it in the +layout.server.js?

Thread Thread
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

I would not recommend using +layout.server.js for auth checking. See more details in this discussion
github.com/sveltejs/kit/issues/6315 or this video https://www.youtube.com/watch?v=UbhhJWV3bmI&ab_channel=Huntabyte

Conrening protected routes and redirects in one place you may follow this advice https://www.youtube.com/watch?v=K1Tya6ovVOI&ab_channel=Huntabyte and move the logic to src/hooks.server.js

Collapse
 
kennek profile image
Kennedy

Thanks for the tutorial. What would the app.d.ts file look like for those of us in love with typescript?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

I am just discussing the update of the official documentaton with Supanase. This documentaton uses TS and can be found here: supabase.com/docs/guides/auth/serv...

In previous documentation for Supabase Auth Helpers there is src/app.d.ts supabase.com/docs/guides/auth/auth...

// src/app.d.ts

import { SupabaseClient, Session } from '@supabase/supabase-js'
import { Database } from './DatabaseDefinitions'

declare global {
  namespace App {
    interface Locals {
      supabase: SupabaseClient<Database>
      getSession(): Promise<Session | null>
    }
    interface PageData {
      session: Session | null
    }
    // interface Error {}
    // interface Platform {}
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jacouys profile image
Jaco

Thanks for the great article!
Pls share the ./utils/addUserprofileToUser.js file

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

Thank you, I am sharing this function in a second edit in the bottom of the article.