Reset password via email with Supabase and Sveltekit

Supabase offer a method to reset your password via email. Most guides I found online skips the part where you detect the event from supabase and securely check that the user is allowed to change password.

In the documentation it says that you can use the resetPasswordForEmail method to generate a link and send it to the user via email:

const { data, error } = await supabase.auth
  .resetPasswordForEmail('user@email.com', {
    redirectTo: "http://localhost:5137/login/newpassword"
  })

This triggers Supabase to send an email to the user with an URL with tokens.

(You can change the email template for this in your supabase dashboard, you can also change the generated redirect url in the same place, eg if you want to add the token directly to the url.)

This standard URL looks kinda like this:
SITE_URL>#access_token=x&refresh_token=y&expires_in=z&token_type=bearer&type=recovery

This link sends the user to Supabases server, they authenticate and logs the user in, and then redirects the user to the URL we provided (localhost:5137/login/newpassword)

The security in this is that only the user should have access to their own email, and thus noone else should get the URL from supabase with the tokens.

Supabase then checks that the tokens in the URL are correct and then redirects the user back to us.

We then have to verify that the onAuthStateChange event is of the type "PASSWORD_RECOVERY", and if it is, we redirect the user to the set new password form.

So lets do some coding, first we make the forgot password page with an forgot_password action.

This is the +page.svelte for "/loing/forgotpassword"

<script lang="ts">
  import { enhance } from "$app/forms";
  export let form;
</script>

<div class="w-[512px] p-2 flex flex-col gap-2">
    <h5 class="font-bold text-2xl">Forgot password?</h5>
    <p>Enter your email to reset your password.</p>
    <form method="POST" action="?/forgot_password" class="flex flex-col gap-2" use:enhance>
        <label for="email">Email:<input type="email" name="email" id="email" required /></label>
        {#if form?.errors?.email}
            <span class="text-red-500">{form?.errors?.email[0]}</span>
        {/if}

        <button type="submit" class="bg-blue-300 text-black font-semibold text-lg border border-solid border-black p-1 w-44 mt-4">Send</button>
    </form>
    {#if form && form.success === true } 
        <div class="bg-green-300 p-2">
            <p>Check your email for the reset-password email from supabase</p>
        </div>
    {/if}
    {#if form && form.success === false } 
        <div class="bg-red-300 p-2">
            <p>Could not find a user with this email.</p>
        </div>
    {/if}

</div>

I get the supabase client and the session from locals, see supabase sveltekit auth helpers how this is set up.
I use Zod for validating form inputs, this is the +page.server.ts for forgotpassword:

import { z } from 'zod';

const changeEmailSchema = z.object({
    email: z.string().email(),
});

export const actions = {
    forgot_password: async (event) => {
        const { request, locals } = event;
        const { supabaseClient } = locals;

        const data = await request.formData();
        const obj = Object.fromEntries(data);

        const { email } = obj;

        try {
            const result = changeEmailSchema.parse(obj);

            const { data: user, error } = await supabaseClient.auth.resetPasswordForEmail(result.email, {
                redirectTo: 'http://localhost:5173/login/newpassword',
            });

            if (error) {
                return {
                    errors: [
                        { field: 'email', message: 'Something went wrong' },
                    ],
                    data: obj,
                    success: false,
                };
            }

            return {
                data: user,
                errors: [],
                success: true,
            };
        } catch (error: any) {
            const { fieldErrors: errors } = error.flatten();

            return {
                errors: errors,
                data: obj,
                success: false,
            };
        }
    },
};

This action generates the email from supabase, and the redirect link that sends the user back to us after supabase authenticated them from the tokens.

Now we have to catch the "PASSWORD_RECOVERY" event from supabase.
I use the Supabase auth helpers package, and the event listener with the auth helper is located in my +layout.svelte file in the root of my routes folder.

This is only a snippet from the file, follow the instructions from the auth helpers package for the full setup.

onMount(() => {
        const {
            data: { subscription },
        } = supabase.auth.onAuthStateChange((event, session) => {
            if (event === "PASSWORD_RECOVERY") {
                // const { url } = $page;
                // const { hash} = url;

                // const token = hash.split('&')[0].slice(14);
                // redirect user to the page where it creates a new password
                throw redirect(302, '/login/newpassword?token=' + session?.access_token);
            } else {
                // default action
                invalidate('supabase:auth');
            }
        });

        return () => {
            subscription.unsubscribe();
        };
    });

I am not sure if Supabase has changed the order of events when you are reading this, there is a discussion/PR where they are thinking of NOT logging the user in before sending the "PASSWORD_RECOVERY" event. If they have changed this, you will not have a session to get the token from. Just remove the token from the url if this is the case.

This onAuthStateChange detects the password recovery and we then redirect the user to our new "change password page".

+page.svelte for /login/newpassword

<script type="ts">
  import { page } from '$app/stores';
  import { enhance } from "$app/forms";

    export let form;

    const { url } = $page;
    const { searchParams } = url;

    const token = searchParams.get('token');
</script>

<div class="w-[512px]">
    <span>Change password</span>
        <form method="POST" action="?/new_password" class="flex flex-col gap-2" use:enhance>
            <label for="password">New password:<input type="password" name="password" id="password" required /></label>
            {#if form?.errors?.password}
                <span class="text-red-500">{form?.errors?.password[0]}</span>
            {/if}
            <label for="confirm_password">Confirm password:<input type="password" name="confirm_password" id="confirm_password" required /></label>
            {#if form?.errors?.confirm_password}
                <span class="text-red-500">{form?.errors?.confirm_password[0]}</span>
            {/if}
            <input type="hidden" name="token" value={token} />
            {#if form?.errors?.token}
                <span class="text-red-500">{form?.errors?.token[0]}</span>
            {/if}
            <button type="submit" class="bg-blue-300 text-black font-semibold text-lg border border-solid border-black p-1 w-44 mt-4">Send</button>
        </form>

    {#if form && form.success === true }
        <div class="bg-green-300 p-2">
            <p>Your password was changed.</p>
        </div>
    {/if}

    {#if form && form.success === false }
        <div class="bg-red-300 p-2">
            <p>Could not change password.</p>
        </div>
    {/if}
</div>

+page.server.ts for "/login/newpassword"

import { error } from '@sveltejs/kit';
import { z } from 'zod';

const changePasswordSchema = z
    .object({
        password: z.string().min(8, { message: 'Password must contain at least 8 characters' }),
        confirm_password: z.string().min(8, { message: 'Password must contain at least 8 characters' }),
        token: z.string(),
    })
    .superRefine((data, ctx) => {
        if (data.password !== data.confirm_password) {
            ctx.addIssue({
                code: 'custom',
                message: 'Passwords must match',
                path: ['confirm_password'],
            });
        }
    });

export const actions = {
    new_password: async (event) => {
        const { request, locals } = event;
        const { supabaseClient, getSession } = locals;
        const session = await getSession();

        // user is logged in from the supabase reset password flow - from +layout.svelte onMount
        if (!session) {
            throw error(401, { message: 'not authorized' });
        }

        const data = await request.formData();
        const obj = Object.fromEntries(data);

        try {
            const result = changePasswordSchema.parse(obj);

            if (result) {
// supabase logged the user in, so we can change the users password
                const { data: user, error } = await supabaseClient.auth.updateUser({
                    password: result.password,
                });

                if (error) {
                    console.log('supa change pw error', error);
                    return {
                        errors: [
                            { field: 'password', message: 'Something went wrong, cant update password' },
                        ],
                        data: {},
                        success: false,
                    };
                }

                if (user) {
                    return {
                        data: user,
                        errors: [],
                        success: true,
                    };
                }
            }
        } catch (error: any) {
            try {
                const { fieldErrors: errors } = error.flatten();
                console.log('catch error', errors);

                return {
                    errors: errors,
                    data: obj,
                    success: false,
                };
            } catch (error2) {
                console.log(error);
            }
        }
    },
};

The user has now changed the password, and is logged in.

You could now redirect them to the frontpage, or where ever you want them to go.