Part 2: Unlocking User Management in SvelteKit+Supabase

We'll cover how to handle user authentication in this part 2 series.

How supabase simplifies data security for our startup

With policies, your database becomes the rules engine. Instead of repetitively filtering your queries, like this ...

const loggedInUserId = 'd0714948'
let { data, error } = await supabase
  .from('users')
  .select('user_id, name')
  .eq('user_id', loggedInUserId)

// console.log(data)
// => { id: 'd0714948', name: 'Jane' }

... you can simply define a rule on your database table, auth.uid() = user_id, and your request will return the rows which pass the rule.

How it works

  1. A user signs up. Supabase creates a new user in the auth.users table.
  2. Supabase returns a new JWT, which contains the user's UUID.
  3. Every request to your database also sends the JWT.
  4. Postgres inspects the JWT to determine the user making the request.
  5. The user's UID can be used in policies to restrict access to rows.

Supabase provides a special function in Postgres, auth.uid(), which extracts the user's UID from the JWT. This is especially useful when creating policies.

User authentication in the app

In our sample app, we are making authentication optional on the marketing pages & mandatory on app pages. We'll work in that logic as we work through creating user accounts in this part 2.

First let's create our signup page. First we're going to create a folder within the routes directory that will wrap all of our authentication pages. This allows us to setup a custom +layout.svelte for all authentication pages to keep them looking similar. Let's create src/routes/(auth) which is the folder wrapper that contains all of our authentication pages.

Let's create the src/routes/(auth)/+layout.svelte page to serve as the wrapper for all of our authentication pages.

<div class="bg-gray-50 min-h-screen">
	<div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
		<div class="mx-auto">
			<div class="font-serif text-4xl text-green-500">deff</div>
		</div>
		<slot />
	</div>
</div>
src/routes/(auth)/+layout.svelte

Now we'll create the signup page at src/routes/(auth)/signup/+page.svelte:

<script lang="ts">
	import { goto } from '$app/navigation';
	import type { PageData } from './$types';
	export let data: PageData;

	$: ({ supabase } = data);

	let loading = false;
	let name: string;
	let email: string;
	let password: string;

	async function handleLogin() {
		try {
			loading = true;
			const { error } = await supabase.auth.signUp({
				email,
				password,
				options: {
					data: { name }
				}
			});
			if (error) throw error;
			goto(`/verify?email=${email}`);
		} catch (error) {
			if (error instanceof Error) {
				alert(error.message);
			}
		} finally {
			loading = false;
		}
	};
</script>

<div class="sm:mx-auto sm:w-full sm:max-w-md">
	<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
		Create an account
	</h2>
	<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
		<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
			<form class="py-4 space-y-6" on:submit|preventDefault={handleLogin}>
				<div>
					<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
					<div class="mt-1">
						<input
							type="text"
							autocomplete="name"
							placeholder="Your name"
							bind:value={name}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
					<div class="mt-1">
						<input
							type="email"
							placeholder="Your email"
							bind:value={email}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>
				<div>
					<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
					<div class="mt-1">
						<input
							type="password"
							placeholder="Your password"
							bind:value={password}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>

				<div>
					<button
						disabled={loading}
						type="submit"
						class="flex w-full justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
					>
						{#if loading}
							Creating account...
						{:else}
							Create account
						{/if}</button
					>
				</div>
			</form>
		</div>
	</div>
</div>

Ok now let's test it out by going to localhost:5173/signup and creating an account. After putting in your name, email, and a password, click sign in. Within the studio at http://localhost:54323/project/default/auth/users, you should see your user account created. You should also see an email within the monitor at http://localhost:54324/monitor containing your verification email. This monitor is a mock emailer listener that handles email notifications for authentication testing locally.

When you click the link to verify your email, it'll take you to a localhost:3000 callback link. We need to modify the supabase/config.toml file to reflect our specific local environment. Make the following changes:

[auth]
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:5173"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:5173"]

You'll need to restart your local dev instance in order to apply these changes:

npx supabase stop && npx supabase start

Try creating a new account at http://localhost:5173/signup. Once you click on the email link you should be directed to the home page with a valid session.

For production purposes, we need to create a verify code page that the user is directed to after signing up & when they click the link. Some email providers scan links which break the "magic links"/"one time link" functionality of our email links we send out. For those users, we'll have a verify link where they can manually enter the OTP code.

Create the verify page at src/routes/(auth)/verify/+page.svelte with the following code:

<script lang="ts">
	import { goto } from '$app/navigation';
	import type { PageData } from './$types';
	export let data: PageData;

	$: ({ supabase } = data);

	let loading = false;
	let token: string;

	const verify = async () => {
		try {
			loading = true;
			const { error } = await supabase.auth.verifyOtp({
				email: data.email!,
				token,
				type: 'signup'
			});
			if (error) throw error;
			await goto('/');
		} catch (error) {
			return alert('Verification code is invalid. Try signing in again.');
		} finally {
			loading = false;
		}
	};

	if (data.token) {
		token = data.token;
	}
</script>

<div class="sm:mx-auto sm:w-full sm:max-w-md">
	<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
		Verify your email address
	</h2>
	<p class="mt-4 text-center text-md text-gray-500">
		Check your email for a magic link. If the link does not log you in automatically, enter the
		verification code below.
	</p>
	<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
		<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
			<form class="py-4 space-y-6" on:submit|preventDefault={verify}>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700"
						>Verification code</label
					>
					<div class="mt-1">
						<input
							type="text"
							autocomplete="off"
							placeholder="Code provided in email"
							bind:value={token}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
					<p class="mt-1 text-sm text-left text-gray-500">
						You should receive an email containing your sign in code at {' '}
						<span class="font-semibold">{data.email}</span>
					</p>
				</div>

				<div>
					<button
						disabled={loading}
						type="submit"
						class="flex w-full justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
					>
						{#if loading}
							Signing in...
						{:else}
							Verify
						{/if}
					</button>
				</div>
			</form>
		</div>
	</div>
</div>

<p class="mt-10 text-center text-sm text-gray-500">
	Didn't receive an email?
	<a href="/login" class="font-semibold leading-6 text-green-600 hover:text-green-500"
		>Try signing in again.</a
	>
</p>

In order to populate the PageData with the required email and token we need to perform a OTP login, we'll retrieve the parameters from the URL. Create a server load page at src/routes/(auth)/verify/+page.server.ts:

// src/routes/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async (event) => {
	const session = await event.locals.getSession();
	if (session) {
		throw redirect(303, '/');
	}
	return {
		email: event.url.searchParams.get('email'),
		token: event.url.searchParams.get('token'),
		error: event.url.searchParams.get('error')
	};
};

Ok now create a new account via the signup flow. When checking the monitor for an email, instead of clicking the link, copy the code. Paste it into the verify box. You should now have a working verify page. We'll reuse this similar logic for a password reset as well. But first let's create a login page.

Next, we'll create the login page at src/routes/(auth)/login/+page.svelte with the following code:

<script lang="ts">
	import { goto } from '$app/navigation';
	import type { PageData } from './$types';
	export let data: PageData;

	$: ({ supabase } = data);

	let loading = false;
	let email: string;
	let password: string;

	const handleLogin = async () => {
		try {
			loading = true;
			const { error } = await supabase.auth.signInWithPassword({
				email,
				password
			});
			if (error) throw error;
			goto(`/verify?email=${email}`);
		} catch (error) {
			if (error instanceof Error) {
				alert(error.message);
			}
		} finally {
			loading = false;
		}
	};
</script>

<div class="sm:mx-auto sm:w-full sm:max-w-md">
	<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
		Sign in to your account
	</h2>
	<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
		<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
			<form class="py-4 space-y-6" on:submit|preventDefault={handleLogin}>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
					<div class="mt-1">
						<input
							type="email"
							placeholder="Your email"
							bind:value={email}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
					<div class="mt-1">
						<input
							type="password"
							placeholder="Your password"
							bind:value={password}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>

				<div class="flex items-center justify-between">
					<div class="flex items-center">
						<input
							id="remember-me"
							name="remember-me"
							type="checkbox"
							class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-600"
						/>
						<label for="remember-me" class="ml-3 block text-sm leading-6 text-gray-900"
							>Remember me</label
						>
					</div>

					<div class="text-sm leading-6">
						<a
							href="/forgot-password"
							class="font-semibold text-green-600 hover:text-green-500"
						>
							Forgot password?
						</a>
					</div>
				</div>

				<div>
					<button
						disabled={loading}
						type="submit"
						class="flex w-full justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
					>
						{#if loading}
							Signing in...
						{:else}
							Sign in
						{/if}</button
					>
				</div>
			</form>
		</div>
	</div>
</div>

<p class="mt-10 text-center text-sm text-gray-500">
	Don't have an account?
	<a href="/signup" class="font-semibold leading-6 text-green-600 hover:text-green-500">Sign up</a>
</p>
src/routes/(auth)/login/+page.svelte

Try logging in with your created account credentials and you should be routed to the home page. Now let's add a check on both the /login and /signup pages to see if the user is logged in. We'll direct them to / if so.

Create a src/routes/(auth)/login/+page.server.ts and src/routes/(auth)/signup/+page.server.ts with the same following typescript code:

import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async (event) => {
	const session = await event.locals.getSession();
	if (session) {
		throw redirect(303, '/');
	}
	return {};
};

If you now try to go to http://localhost:5173/login you'll notice you get redirected to http://localhost:5173/. We can add a logout button to src/routes/+page.svelte by modifying it to:

<script lang="ts">
	import type { PageData } from './$types';
	export let data: PageData;

	$: ({ supabase, session } = data);

	async function logout() {
		await supabase.auth.signOut();
	}
</script>

<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

<button on:click={logout}> logout</button>
<pre>{JSON.stringify(session)}</pre>

Clicking logout now clears the session. You can go to http://localhost:5173/login and re-login again.

Forgot password functionality

Now let's build out the reset password functionality. First let's build out a link in the /login page to allow users to go to the /forgot-password page.

Create the following files:

<script lang="ts">
	import { goto } from '$app/navigation';
	import type { PageData } from './$types';
	export let data: PageData;

	$: ({ supabase } = data);

	let loading = false;
	let email: string;

	async function forgot() {
		if (!email) {
			return alert('enter an email to reset password');
		}
		const { error } = await supabase.auth.resetPasswordForEmail(email, {
			redirectTo: `${location.origin}/reset-password`
		});
		if (error) console.error(error);
		goto('/reset-password');
	}
</script>

<div class="sm:mx-auto sm:w-full sm:max-w-md">
	<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
		Reset your password
	</h2>
	<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
		<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
			<form class="py-4 space-y-6" on:submit|preventDefault={forgot}>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
					<div class="mt-1">
						<input
							type="email"
							placeholder="Your email"
							bind:value={email}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>

				<div>
					<button
						disabled={loading}
						type="submit"
						class="flex w-full justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
					>
						{#if loading}
							Sending reset email...
						{:else}
							Reset
						{/if}
					</button>
				</div>
			</form>
		</div>
	</div>
</div>

<p class="mt-10 text-center text-sm text-gray-500">
	Remember your password?
	<a href="/login" class="font-semibold leading-6 text-green-600 hover:text-green-500">Log in</a>
</p>
src/routes/(auth)/forgot-password/+page.svelte

Add a redirect for logged in users so they aren't triggering password resets:

import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async (event) => {
	const session = await event.locals.getSession();
	if (session) {
		throw redirect(303, '/');
	}
	return {};
};
src/routes/(auth)/forgot-password/+page.server.ts

And lastly, lets create the page to allow users to change their password upon redirect from the password reset email

<script lang="ts">
	import { goto } from '$app/navigation';
	import type { PageData } from './$types';
	export let data: PageData;

	$: ({ supabase } = data);

	let loading = false;
	let password1: string;
	let password2: string;

	async function reset() {
		if (!password1 || !password2) {
			return alert('enter an email to reset password');
		}
		if (password1 !== password1) {
			return alert('passwords dont match');
		}
		const { error } = await supabase.auth.updateUser({
			password: password2
		});
		if (error) console.error(error);
		goto('/');
	}
</script>

<div class="sm:mx-auto sm:w-full sm:max-w-md">
	<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
		Reset your password
	</h2>
	<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
		<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
			<form class="py-4 space-y-6" on:submit|preventDefault={reset}>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700">Password</label>
					<div class="mt-1">
						<input
							type="password"
							placeholder="Enter a password"
							bind:value={password1}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>
				<div>
					<label for="email" class="block text-sm font-medium text-gray-700">Confirm password</label
					>
					<div class="mt-1">
						<input
							type="password"
							placeholder="Confirm your password"
							bind:value={password2}
							class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-green-500 focus:outline-none focus:ring-green-500 sm:text-sm"
						/>
					</div>
				</div>

				<div>
					<button
						disabled={loading}
						type="submit"
						class="flex w-full justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
					>
						{#if loading}
							Sending reset password email
						{:else}
							Reset your password
						{/if}
					</button>
				</div>
			</form>
		</div>
	</div>
</div>
src/routes/(auth)/reset-password/+page.svelte

Social authentication w/ Google sign in

Let's add in google sign in to our application. To do that, first let's get an oauth credential. Create a new project via google's developer console and then create an oauth 2.0 key.

Be sure to add the following urls to the authorized redirect urls (replacing xxx with your supabase project id):

https://xxxxxxxxx.supabase.co/auth/v1/callback
http://localhost:54321/auth/v1/callback

Next, add the following configuration to to the config.toml

# supabase/config.toml

[auth.external.google]
enabled = true
client_id = "1058874065202-9dtif4drc6bkd35lobtoktvo5xxxxxx.apps.googleusercontent.com"
secret = "GOCSPX-YU8MZTCqnIK9y_xxxxxxxxx_O"
# Overrides the default auth redirectUrl.
# redirect_uri = ""

After changing your configuration file, be sure to restart your supabase instance to take effect: npx supabase stop && npx supabase start

Go ahead and click sign in with google and you should be able to redirect to the logged in page.

UPDATE: Leveraging server side PKCE

First let's upgrade the supabase sveltekit auth helper:

"@supabase/auth-helpers-sveltekit": "^0.10.0",
Change the version of the auth helpers in package.json

Next we'll create the authentication callback to exchange a code for a session by creating the callback endpoint

import { redirect } from '@sveltejs/kit'

export const GET = async ({ url, locals: { supabase } }) => {
  const code = url.searchParams.get('code')

  if (code) {
    await supabase.auth.exchangeCodeForSession(code)
  }

  throw redirect(303, '/')
}
src/routes/(auth)/auth/callback/+server.ts

Lastly let's update each callback url for login/signup to redirect to the /auth/callback url.

async function handleLogin() {
	try {
		loading = true;
		const { error } = await supabase.auth.signUp({
			email,
			password,
			options: {
				data: { name },
				emailRedirectTo: `${location.origin}/auth/callback`
			}
		});
		if (error) throw error;
		goto(`/verify?email=${email}`);
	} catch (error) {
		if (error instanceof Error) {
			alert(error.message);
		}
	} finally {
		loading = false;
	}
}
Update the src/routes/(auth)/signup/+page.svelte handleLogin function to include the email direct to the callback url.