Skip to content

j4w8n/supakit

Repository files navigation

Supakit

A Supabase auth helper for SvelteKit.

v2.0.0 is breaking, as it requires the following:

  • @sveltejs/kit ^2.4.1
  • svelte >=4.2.9
  • vite ^5.0.12

Differences from the official Supabase Sveltekit auth helper

  • Uses httpOnly cookie storage, for tighter security against XSS. This includes CSRF protection for the endpoints that Supakit creates.1
  • "Remember Me" feature.
  • Option to set flowType and debug for client auth.
  • Provides a callback route for server-side auth, so you don't have to setup exchangeCodeForSession().
  • Provides a confirm route for server-side token hash otp verification.
  • Built-in server client, again less setup for you.
  • Saves the provider_token and provider_refresh_token in their own httpOnly cookies. These values are also available in event.locals.session. Please note that Supakit will not refresh these tokens for you.
  • Offers a secure client-side "session" store, which is hydrated with Supabase session info after most auth events. This helps with immediate reactivity after these events occur. The invalidate() method is an alternative to this.
  • Opt-out of server-side features (client, event.locals); but very uncommon.

Table of Contents

Install

Setup

Types

Load Client

onAuthStateChange

Server Hooks

event.locals

Server Client

Server-side Auth

Client-side Auth

Session Store

Cookies

Remember Me

Troubleshooting

Protect Routes

Install

npm install supakit

pnpm add supakit

yarn add supakit

Setup

For the examples, we are assuming that you use Typescript and are generating database types to $lib/database.d.ts

Environment

Create an .env file in the root of your project, with your PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY values; and/or ensure these are set on your deployment platform.

Types

Per Supabase docs, you can also add your database types to your client. For other options, beside linking, checkout the docs.

In your local CLI:

supabase link --project-ref <project-id>
supabase gen types typescript --linked > src/lib/database.d.ts

Ensure your app.d.ts file includes the following.

cookie_options is only needed if you plan to set additional cookies on the server-side and wanna use Supakit's cookie options; or if you pass in custom cookie options. See Cookie Options to learn how to change the defaults.

/* src/app.d.ts */
import { SupabaseClient, Session } from '@supabase/supabase-js'
import { Database } from '$lib/database.d'
import { SecureCookieOptionsPlusName } from 'supakit'

declare global {
  namespace App {
    interface Locals {
      cookie_options: SecureCookieOptionsPlusName;
      session: Session | null;
      supabase: SupabaseClient<Database>;
    }
    interface PageData {
      session: Session | null;
      supabase: SupabaseClient<Database>;
    }
  }
}

Load client and options

Create a Supabase client in your root +layout.ts file. We're using $env/dynamic in the example, but you can also use $env/static if it's a better fit for your use-case.

This client will now be available in either data or $page.data in your downstream load functions and pages.

Pass in an object of Supabase Client Options as the fourth parameter to createSupabaseLoadClient. Any options you pass in here, you'll want to setup for the server client as well.

Since Supakit only allows certain auth options, we've included them below. Type defaults are shown as the last option.

Auth Types:

flowType?: 'implicit' | 'pkce'
debug?: true | false

If you want to use SvelteKit's native invalidate method, after session changes, be sure to use depends below. Otherwise, you can omit and setup the session store.

/* src/routes/layout.ts */
import { env } from '$env/dynamic/public'
import { createSupabaseLoadClient } from 'supakit'
import type { Database } from '$lib/database.d'

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

  const supabase = createSupabaseLoadClient<Database>(
    env.PUBLIC_SUPABASE_URL, 
    env.PUBLIC_SUPABASE_ANON_KEY,
    session
  )

  return { supabase, session }
}

Declare onAuthStateChange

Listen for auth changes in your root +layout.svelte file. You'll need to pass-in your Supabase load client as the first parameter.

If using depends to invalidate data after session changes, be sure to setup the callback function. Otherwise, reference auth state to use Supakit's session store.

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { supabaseAuthStateChange } from 'supakit'
  import { onMount } from 'svelte'

  export let data

  onMount(() => {
    supabaseAuthStateChange(data.supabase, null, ({ event, session }) => {
      if (data.session.expires_at !== session.expires_at) invalidate('supabase:auth')
    }))
  })
</script>

Server hooks

Handles endpoints, setting event.locals, and initializing the Supabase server client.

/* src/hooks.server.ts */
import { supakit } from 'supakit'

export const handle = supakit

Supakit Lite

If you don't want Supakit to handle server-side features like populating event.locals and creating a server client, then you can use a different import which will only handle setting cookies and the server-side auth callback.

/* src/hooks.server.ts */
import { supakitLite } from 'supakit'

export const handle = supakitLite

Locals

There's nothing for you to do here, just a heads-up that these are available to use:

/* Session info from Supabase */
event.locals.session = {
  provider_token,
  provider_refresh_token,
  access_token,
  refresh_token,
  expires_in,
  expires_at,
  token_type,
  user
}

/* Supakit's server-side Supabase client */
event.locals.supabase

/**
 * If you want to set your own cookies on the server-side,
 * you can use Supakit's default options - or your custom
 * options, if you've set them (see further below).
 */
event.locals.cookie_options

expires_in will get calculated, and reflect how many seconds are left until your access_token expires. expires_at is taken directly from the jwt. Keep in mind that these two values are only updated when the handle function is called in hooks.server.ts; so don't rely on them for realtime info.

Server client and options

The built-in Supabase server client relies on $env/dynamic/public. If there is a logged-in user, they're automatically "signed in" to this client.

/* some server-side load file, for example src/routes/+layout.server.ts */
export const load = ({ locals: { session, supabase } }) => {
  const { data, error } = await supabase.from('table').select('column')

  return {
    stuff: data,
    session
  }
}

If you'd like to set client options for the server client, pass them into a function at the root of your server hooks. You can reference them at SupabaseClientOptions.

Since Supakit only allows certain auth options, we've included them below. Type defaults are shown as the last option.

Auth Types:

flowType?: 'implicit' | 'pkce'
debug?: true | false

There's no compelling reason to set flowType to implicit for a server client, because any implicit flows would happen in the browser.

/* src/hooks.server.ts */
import { supakit, setSupabaseServerClientOptions } from 'supakit'

setSupabaseServerClientOptions({
  client_options: {
    auth: {
      debug: true
    }
  }
})

export const handle = supakit

Server-side auth

Exchange a code for a session

Supakit provides an endpoint for handling the exchangeCodeForSession method; so there's no need to create this route yourself. You can also append the redirectTo url with a next parameter for post-auth redirects, e.g./app.

Here we show an example using page actions.

/* some server-side file, like src/routes/login/+page.server.ts */
export const actions = {
  signin: async ({ request, url, locals: { supabase } }) => {
    const data = await request.formData()
    const provider = data.get('provider') as Provider
    const { data, error } = await supabase.auth.signInWithOAuth({ 
      provider,
      options: {
        redirectTo: `${url.origin}/supakit/callback?next=/app`
      }
    })

    /* handle error */

    if (data.url) throw redirect(303, data.url)
  }
}
<!-- some client-side file, like src/routes/login/+page.svelte -->
<form method="POST" action="?/signin">
  <button name="provider" value="github">GitHub</button>
  <button name="provider" value="google">Google</button>
</form>

Login with a token_hash

This method can be used for the following purposes:

  1. Confirm a signup
  2. Invite a user
  3. Magic Link email signin
  4. Reset a password

Be sure to change your email templates per the Supabase guide. When doing so, change any references of /auth/confirm to /supakit/confirm. Also, change any next parameter to the appropriate page on your site.

Magic Link email Example

const { data, error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com'
})

Reset a password Example

const { data, error } = await supabase.auth.resetPasswordForEmail({
  email: 'user@example.com'
})

Client-side auth

If you'd rather sign-in on the client-side, set your client's flowType to implicit and use typical Supabase sign-in methods.

Load client example:

/* src/routes/layout.ts */
import { env } from '$env/dynamic/public'
import { createSupabaseLoadClient } from 'supakit'
import type { Database } from '$lib/database.d'

export const load = async ({ data: { session } }) => {
  const supabase = createSupabaseLoadClient<Database>(
    env.PUBLIC_SUPABASE_URL, 
    env.PUBLIC_SUPABASE_ANON_KEY, 
    session, {
      auth: {
        flowType: 'implicit'
      }
    }
  )

  return { supabase, session }
}

Further Reading and Options

Session Store

getSessionStore() is a secure, session store using Svelte's context API. If you pass the store into supabaseAuthStateChange(), Supakit will automatically hydrate the store with the returned Supabase session info after the INITIAL_SESSION, SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, and USER_UPDATED events - giving you immediate reactivity for any part of your app that relies on the value of the store.

To hydrate the store during initial load and page refreshes, you can populate the store with returned server data.

Setup

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { onMount } from 'svelte'
  import { getSessionStore, supabaseAuthStateChange } from 'supakit'

  export let data
  const session_store = getSessionStore()

  /**
   * data.session assumes you return `session` from a file 
   * like src/routes/+layout.server.ts with code such as:
   * return {
   *   session: event.locals.session
   * }
   * 
   * and then from src/routes/+layout.ts with code such as:
   * return {
   *   session: data.session
   * }
   */
  $session_store = data.session

  onMount(() => {
    supabaseAuthStateChange(data.supabase, session_store)
  })
</script>

Page Usage

<!-- /src/routes/some/path/+page.svelte -->
<script lang="ts">
  import { getSessionStore } from 'supakit'
  const session_store = getSessionStore()
</script>

{#if $session_store}
  <h4>Your id is {$session_store.user.id}</h4>
{/if}

Auth State

supabaseAuthStateChange() handles logic for Supabase's onAuthStateChange(). A Supabase client is required to be passed-in. Then it takes an optional Svelte store, and a callback function. The callback function receives the Supabase { event, session } object as a parameter, for doing additional work after an auth event.

If you pass in a store, Supakit will hydrate it with the Supabase session after the INITIAL_SESSION, SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, and USER_UPDATED events. This option is usually used in lieu of SvelteKit's invalidate() and depends() functions. Using both methods would be uncommon.

Type:

supabaseAuthStateChange(
  client: SupabaseClient, 
  store?: Writable<Session | null> | null, 
  callback?: (({ event, session }: { event: string, session: Session | null }) => void)
)

Example:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { goto, invalidate } from '$app/navigation'
  import { onMount } from 'svelte'
  import { getSessionStore, supabaseAuthStateChange } from 'supakit'

  export let data
  const session_store = getSessionStore()

  /**
   * data.session assumes you return `session` from a file 
   * like src/routes/+layout.server.ts or +layout.ts with code such as:
   * return {
   *   session: event.locals.session
   * }
   */
  $session_store = data.session

  onMount(() => {
    supabaseAuthStateChange(data.supabase, session_store, ({ event, session }) => {
      /**
       * Put any post-event code here.
       * 
       * e.g. If you don't use Supakit's session store, then you'll likely want
       * to add the below; as well as change `session_store`, above, to `null`.
       */
      if (data.session.expires_at !== session.expires_at) invalidate('supabase:auth')
    })
  })
</script>

Cookies

Supakit will set upto four cookies.

  • sb-<supabase_project_id>-auth-token
  • sb-provider-token
  • sb-provider-refresh-token
  • sb-<crypto.randomUUID()>-csrf

sb-<supabase_project_id>-auth-token, or your custom storage key, is updated after the INITIAL_SESSION, SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, and USER_UPDATED events. The provider cookies will only be set after the initial SIGNED_IN event, and will need to be refreshed and updated by you. The csrf cookie is a session cookie, used to help secure the /supakit/cookie endpoint for cookie storage; and you may notice more than one.

Supakit sets httpOnly cookies by making route requests to the server, then returning a response with the set-cookie header. These requests are handled programmatically by Supakit. There is no need for you to create routes for this purpose.

Supakit maintains a session cache inside it's custom CookieStorage for the Supabase load client. When retrieving a user session, for a call like supabase.auth.getSession() for example, Supakit will return the cached response as long as the session is not null; saving a trip to the server.

Supakit uses the special, programmed routes /supakit/cookie and /supakit/csrf. Therefore, you should not have a top-level route with the same name (not that anyone would).

httpOnly Cookie Exception

Because Supakit uses secure httpOnly cookie storage: setting, getting, and deleting the session requires a request to the server. This causes a delay between sending the request, receiving the response, and finally setting the cookie in the browser. This can cause a timing issue after a user logs in for the first time; specifically if you have callback code for supabaseAuthStateChange(). To work around this, Supakit will set a non-httpOnly cookie which expires in 5 seconds. This allows any affected code to send the temporary cookie sb-temp-session to the server, so that the server will know someone is logged in. This cookie exists in the browser, until it's closed; but once it has expired, it cannot be accessed via XSS attacks.

Cookie Options

You can set your own options by passing in an object of SecureCookieOptions plus name for a custom cookie storage key. Whatever you pass in will be merged with the defaults - overriding when appropriate. Cookie options should be the same for the Load client and Server client.

This is the fifth parameter for createSupabaseLoadClient.

Type:

type SecureCookieOptions = Omit<CookieSerializeOptions, "httpOnly"> & { name?: string }

Supakit Defaults:

{ 
  path: '/',
  maxAge: 60 * 60 * 24 * 365 // one year
}

Load client example:

/* src/routes/layout.ts */
import { env } from '$env/dynamic/public'
import { createSupabaseLoadClient } from 'supakit'
import type { Database } from '$lib/database.d'

export const load = async ({ data: { session } }) => {
  const supabase = createSupabaseLoadClient<Database>(
    env.PUBLIC_SUPABASE_URL, 
    env.PUBLIC_SUPABASE_ANON_KEY,
    session,
    {}, /* Supabase client options is the fourth parameter */
    {
      maxAge: 60 * 60 * 24 * 365 * 100,
      sameSite: 'strict',
      name: 'your-custom-storage-key' // replaces `sb-<supabase_project_id>-auth-token`
    }
  )

  return { supabase, session }
}

Server client example:

/* src/hooks.server.ts */
import { supakit, setSupabaseServerClientOptions } from 'supakit'

setSupabaseServerClientOptions({
  cookie_options: {
    maxAge: 180
  }
})

export const handle = supakit

By default SvelteKit sets httpOnly and secure to true, and sameSite to lax. Supakit relies on the httpOnly value to be true for better cookie security. Typescript will show an error if you try to pass it in.

If you need to set your own cookies, you can import getSupabaseLoadClientCookieOptions on the client-side; or by using event.locals.cookie_options on the server-side.

Examples:

/* client-side */
import { getSupabaseLoadClientCookieOptions } from 'supakit'

const cookie_options = getSupabaseLoadClientCookieOptions()
/* some server-side file with locals available - example here is a hooks handler */
export const yourHandler = (async ({ event, resolve }) => {
  if ('some check') {
    const response = new Response(null)
    response.headers.append('set-cookie', event.cookies.serialize(`cookie-name`, token, event.locals.cookie_options))
    return response
  }

  return await resolve(event)
}) satisfies Handle

Remember Me

Determines if Supakit remembers a logged-in user or if they are logged out after closing the browser. Defaults to true, whether you use the feature or not.

You can give users the option by adding a checkbox input to your login form, then importing and using the function.

/* src/routes/login/+page.svelte */
<script lang="ts">
  import { rememberMe } from 'supakit'
  import { onMount } from 'svelte'

  let checked: boolean
  onMount(() => {
    checked = rememberMe().value
  })
</script>

<form method="POST" action="?/signin">
  <button name="provider" value="github">Login with GitHub</button>
  <input 
    type="checkbox" id="rememberme" 
    bind:checked 
    on:click={ () => checked = rememberMe().toggle }/>
  <label for="rememberme">Remember Me</label>
</form>

Troubleshooting

You can pass the debug option to your clients and see verbose logs for auth.

export const load = async ({ data: { session } }) => {
  const supabase = createSupabaseLoadClient<Database>(
    env.PUBLIC_SUPABASE_URL, 
    env.PUBLIC_SUPABASE_ANON_KEY,
    session,
    {
      auth: {
        debug: true
      }
    }
  )

  return { supabase, session }
}

Protecting Routes

Sometimes you want a user to be logged in, in order to access certain pages.

Here is our example file structure, where routes /admin and /app should be protected. We place these routes under a layout group, so they can share a +layout.server.ts file. However, this isn't strictly required unless you need shared data across pages under the group.

src/routes/
├ (auth)/
│ ├ admin/
│ │ ├ +page.server.ts
│ │ └ +page.svelte
│ ├ app/
│ │ ├ +page.server.ts
│ │ └ +page.svelte
│ ├ +layout.server.ts
│ └ +layout.svelte
├ login/
│ └ +page.svelte
├ +error.svelte
├ +layout.server.ts
├ +layout.svelte
└ +page.svelte

Server-side Layout

When using a +layout.server.ts file, check for a null locals.session. If there's no session, then the server client won't be authenticated with a user and will act as an anon client.

/* src/routes/(auth)/+layout.server.ts */
import { redirect } from '@sveltejs/kit'

export const load = async ({ locals: { session, supabase } }) => {
  if (!session) throw redirect(307, '/login')

  /* grab info to return */
  let { data, error } = await supabase.from('table').select('column')

  return {
    stuff: data,
    session
  }
}

Server-side and Client-side Page

Protect pages using a +page.server.ts file for each page route. This is needed because +layout.server.ts will not necessarily run on every request. This method works for both client-side and server-side requests.

/* src/routes/(auth)/app/+page.server.ts */
import { redirect } from '@sveltejs/kit'

export const load = async ({ locals: { session } }) => {
  if (!session) throw redirect(307, '/login')
}