├── .env.example ├── .gitignore ├── .npmrc ├── README.md ├── package.json ├── src ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── server │ │ └── event.ts │ └── utils.ts └── routes │ ├── (authenticated) │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── app │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── self │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ └── auth │ ├── +page.server.ts │ ├── +page.svelte │ ├── callback │ └── +server.ts │ └── confirm │ └── +server.ts ├── static └── favicon.png ├── supabase ├── config.toml └── seed.sql ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_SUPABASE_ANON_KEY="" 2 | PUBLIC_SUPABASE_URL="" 3 | SUPABASE_SERVICE_ROLE_KEY="" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | supabase/.temp/* 12 | src/lib/database.d.ts 13 | bun.lockb 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auth and User Demo 2 | 3 | Uses SvelteKit, Supabase, and SSR Auth. 4 | 5 | ## Code Showcase 6 | 7 | - Email sign-up/sign-in. 8 | - Phone OTP sign-in. 9 | - Reset password for email sign-in. 10 | - Anonymous sign in. 11 | - Convert Anonymous user to permanent user. 12 | - GitHub sign-in. Can easily be changed to other oauth providers. 13 | - Requires a session to access all pages under the `authenticated` layout group. 14 | - Add, change, remove custom `nickname` user_metadata on the `/self` page. 15 | - Add or change a user's phone number on the `/self` page. 16 | - Delete a user on the `/self` page - if needed, when playing around with the demo. 17 | 18 | > All actions happen server-side. 19 | 20 | ## Prerequisites 21 | 22 | 1. A Supabase account. 23 | 2. A Supabase project. 24 | 25 | ## Install 26 | 27 | ``` 28 | git clone https://github.com/j4w8n/sveltekit-supabase-ssr.git 29 | cd sveltekit-supabase-ssr 30 | npm install 31 | ``` 32 | 33 | ## Setup 34 | 35 | 1. Environment variables 36 | 37 | Rename the `.env.example` file to `.env.local` in your project's root directory and assign values from your [dashboard](https://supabase.com/dashboard/project/_/settings/api). 38 | ``` 39 | PUBLIC_SUPABASE_ANON_KEY= 40 | PUBLIC_SUPABASE_URL=https://.supabase.co 41 | SUPABASE_SERVICE_ROLE_KEY= 42 | ``` 43 | 44 | 2. Email Templates - [link](https://supabase.com/dashboard/project/_/auth/templates) 45 | 46 | If using the signup, magiclink, or reset password features, change their template anchor links per the below. 47 | 48 | All need this: `href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"`, which should replace the `{{ .ConfirmationURL }}`, and then there are some additions for magic link and reset: 49 | 50 | - Magic Link: add `&next=/app` at the end of the above href. 51 | - Reset Password: add `&next=/self` at the end of the above href. 52 | 53 | 3. Site URL and Redirect URLs - [link](https://supabase.com/dashboard/project/_/auth/url-configuration) 54 | 55 | Ensure these are set. 56 | - Site: 57 | - `http://localhost:5173` 58 | - Redirects: 59 | - `http://localhost:5173/auth/confirm` 60 | - `http://localhost:5173/auth/callback` 61 | 62 | 4. User and provider settings - [link](https://supabase.com/dashboard/project/_/auth/providers) 63 | - Under "User Signups", ensure that all three settings are enabled. 64 | - Under "Auth Providers", enable and configure the relevant ones for you. 65 | - If using the phone OTP login, you must setup an SMS provider. You can use Twilio Verify and get a $15 credit. 66 | 67 | ## Run! 68 | 69 | ``` 70 | npm run dev 71 | ``` 72 | 73 | Open a browser to http://localhost:5173 74 | 75 | ## Security 76 | 77 | Within the `(authenticated)` layout group, we have a `+page.server.ts` file for each route. This ensures that even during "client-side navigation" the `hooks.server.ts` file is run so that we can verify there's still a session before rendering the page. I put that in double-quotes because this process essentially disables client-side navigation for these pages. 78 | 79 | We check for and fully validate the session by calling `event.locals.getSession()`. Inside that function, we call `getClaims` to verify the `access_token`, aka JWT, and use it's decoded contents to help create a validated session for use on the server-side. This validation is important because sessions are stored in a cookie sent from a client. The client could be an attacker with just enough information to bypass checks in a simple `supabase.auth.getSession()` call, and possibly render data for a victim user. See [this discussion](https://github.com/orgs/supabase/discussions/23224) for details. 80 | 81 | !!! Just verifying the JWT does not validate other information within getSession's `session.user` object; this is a big reason why we do the "full validation" by replacing its contents using info from the verified and decoded JWT. See discussion link above. !!! 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-supabase-ssr", 3 | "version": "0.16.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite build && vite preview", 9 | "debug": "vite --force", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 12 | }, 13 | "devDependencies": { 14 | "@sveltejs/adapter-auto": "^6.0.1", 15 | "@sveltejs/kit": "^2.21.2", 16 | "@sveltejs/vite-plugin-svelte": "^5.1.0", 17 | "svelte": "^5.33.14", 18 | "svelte-check": "^4.2.1", 19 | "tslib": "^2.8.1", 20 | "typescript": "^5.8.3", 21 | "vite": "^6.3.5" 22 | }, 23 | "type": "module", 24 | "dependencies": { 25 | "@supabase/ssr": "^0.6.1", 26 | "@supabase/supabase-js": "^2.49.10" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | import { SupabaseClient, Session } from '@supabase/supabase-js' 2 | import { Database } from './DatabaseDefinitions' 3 | 4 | declare global { 5 | namespace App { 6 | interface Locals { 7 | supabase: SupabaseClient 8 | getSession(): Promise 9 | } 10 | interface PageData { 11 | session: Session | null 12 | supabase: SupabaseClient 13 | } 14 | // interface Error {} 15 | // interface Platform {} 16 | } 17 | } 18 | 19 | export {} 20 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public' 2 | import { createServerClient } from '@supabase/ssr' 3 | import { redirect } from '@sveltejs/kit' 4 | import type { Session } from '@supabase/supabase-js' 5 | import { getValidatedSession } from '$lib/utils.js' 6 | 7 | export const handle = async ({ event, resolve }) => { 8 | event.locals.supabase = createServerClient( 9 | PUBLIC_SUPABASE_URL, 10 | PUBLIC_SUPABASE_ANON_KEY, 11 | { 12 | cookies: { 13 | getAll: () => event.cookies.getAll(), 14 | setAll: (cookies) => { 15 | cookies.forEach(({ name, value, options }) => { 16 | event.cookies.set(name, value, { ...options, path: '/' }) 17 | }) 18 | } 19 | } 20 | } 21 | ) 22 | 23 | /** 24 | * We use getSession, as a function, rather than a static object 25 | * like `session`, in order to make reactivity work for some 26 | * features of our pages. For example, if this wasn't a function, 27 | * things like the `update_nickname` form action in /self 28 | * wouldn't correctly update data on its page. 29 | */ 30 | event.locals.getSession = async (): Promise => { 31 | return await getValidatedSession(event.locals.supabase) 32 | } 33 | 34 | const session = await event.locals.getSession() 35 | 36 | /** 37 | * Only authenticated users can access these paths and their sub-paths. 38 | * 39 | * If you'd rather do this in your routes, see (authenticated)/app/+page.server.ts 40 | * for an example. 41 | * 42 | * If you don't use a layout group for auth-protected paths, then you can use 43 | * new Set(['app', 'self']) or whatever your top-level path segments are, and 44 | * .has(event.url.pathname.split('/')[1]) 45 | */ 46 | const auth_protected_paths = new Set(['(authenticated)']) 47 | if (!session && auth_protected_paths.has(event.route.id?.split('/')[1] || '')) 48 | redirect(307, '/auth') 49 | 50 | return resolve(event, { 51 | filterSerializedResponseHeaders(name) { 52 | return name === 'content-range' || name === 'x-supabase-api-version' 53 | }, 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/server/event.ts: -------------------------------------------------------------------------------- 1 | import { getRequestEvent } from "$app/server" 2 | 3 | /** 4 | * Returns the formData items you request, 5 | * from an `event`. 6 | * 7 | * @example const { email, password } = await getFormData('email', 'password') 8 | */ 9 | export const getFormData = async < 10 | T = string, 11 | K extends string = string 12 | >(...items: K[]): Promise<{ [key in K]: T | null }> => { 13 | const { request } = getRequestEvent() 14 | const data = await request.formData() 15 | const result: { [key: string]: T | null } = {} 16 | 17 | for (const i of items.values()) { 18 | result[i] = data.get(i) as T 19 | } 20 | 21 | return result as { [key in K]: T | null } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { SupabaseClient, Session } from "@supabase/supabase-js" 2 | 3 | /** 4 | * Validate a session on the client or server side. 5 | */ 6 | export const getValidatedSession = async (supabase: SupabaseClient): Promise => { 7 | const session = (await supabase.auth.getSession()).data.session 8 | 9 | if (!session) return null 10 | 11 | /* We wrap getClaims in a try/catch, because it could throw. */ 12 | try { 13 | /** 14 | * If your project is using symmetric JWTs, 15 | * getClaims makes a network call to your Supabase instance. 16 | * 17 | * We pass the access_token into getClaims, otherwise it 18 | * would call getSession itself - which we've already done above. 19 | * 20 | * If you need data that is only returned from `getUser`, 21 | * then you can substitute it here and assign accordingly in the return statement. 22 | */ 23 | const { data, error } = await supabase.auth.getClaims(session.access_token) 24 | 25 | if (error || !data) return null 26 | 27 | const { claims } = data 28 | 29 | /** 30 | * Return a Session, created from validated claims. 31 | * 32 | * For security, the only items you should use from `session` are the access and refresh tokens. 33 | * 34 | * Most of these properties are required for functionality or typing. 35 | * Add any data needed for your layouts or pages. 36 | * 37 | * Here are the properties which aren't required, but we use them in the demo: 38 | * `user.user_metadata.avatar_url` 39 | * `user.user_metadata.nickname` 40 | * `user.email` 41 | * `user.phone` 42 | */ 43 | return { 44 | access_token: session.access_token, 45 | refresh_token: session.refresh_token, 46 | expires_at: claims.exp, 47 | expires_in: claims.exp - Math.round(Date.now() / 1000), 48 | token_type: 'bearer', 49 | user: { 50 | app_metadata: claims.app_metadata ?? {}, 51 | aud: 'authenticated', 52 | created_at: '', // only found in session.user or getUser 53 | id: claims.sub, 54 | email: claims.email, 55 | phone: claims.phone, 56 | user_metadata: claims.user_metadata ?? {}, 57 | is_anonymous: claims.is_anonymous 58 | } 59 | } 60 | } catch (err) { 61 | console.error(err) 62 | return null 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const load = async ({ setHeaders }) => { 2 | /** 3 | * Prevents browsers from caching pages which should be protected in all scenarios. 4 | * 5 | * This is not strictly necessary, but be aware of the following: 6 | * If a user is logged in and uses a feature that utilizes a form action, 7 | * when that form action returns, the browser URL still contains the search param. 8 | * e.g. http://localhost:5173/app?/some_form_action 9 | * 10 | * If the user then logs out and clicks/taps the browser back navigation button, 11 | * some browsers will serve the previous page via cache, potentially exposing 12 | * sensitive data. I understand this example is a bit contrived, since 13 | * the user just saw the data before logging out. 14 | * 15 | * If you aren't concerned about the above, I'd still recommend 16 | * setting this to 'private' so sensitive data can't be leaked across users via the cache. 17 | */ 18 | setHeaders({ 19 | 'cache-control': 'no-store' 20 | }) 21 | } -------------------------------------------------------------------------------- /src/routes/(authenticated)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

You're inside the auth-protected (authenticated) layout group!

6 | {@render children?.()} 7 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/app/+page.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Auth validation happens in hooks.server.ts, so there's 3 | * no need to check anything here. Plus, we aren't returning 4 | * server data to the /app page; so, this file only exists 5 | * to trigger a server call for client-side routing, to check authentication. 6 | * 7 | * If you have a one-off situation for authentication, 8 | * or you'd rather be more explicit, check for a session and redirect. 9 | * 10 | * import { redirect } from '@sveltejs/kit' 11 | * 12 | * export const load = async ({ locals: { getSession } }) => { 13 | * const session = await getSession() 14 | * if (!session) redirect(307, '/auth') 15 | * } 16 | */ 17 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/app/+page.svelte: -------------------------------------------------------------------------------- 1 |

Welcome to /app!

2 |

You wouldn't be here unless you're authenticated.

3 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/self/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { Provider } from "@supabase/supabase-js" 2 | import { fail, redirect } from "@sveltejs/kit" 3 | import { PUBLIC_SUPABASE_URL } from '$env/static/public' 4 | import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private' 5 | import { createClient } from '@supabase/supabase-js' 6 | import { getFormData } from "$lib/server/event.js" 7 | 8 | export const load = async ({ locals: { getSession } }) => { 9 | return { session: await getSession() } 10 | } 11 | 12 | export const actions = { 13 | convert_email: async({ locals: { supabase } }) => { 14 | const { email } = await getFormData('email') 15 | 16 | if (!email) { 17 | return fail(400, { 18 | error: 'Please provide your email address.' 19 | }) 20 | } 21 | 22 | const { error } = await supabase.auth.updateUser({ email }) 23 | 24 | if (error) 25 | return fail(error.status ?? 400, { error: error.message }) 26 | 27 | return { 28 | message: 'Please check your email for the OTP code and enter it below, along with your new password.' , 29 | verify: true, 30 | password_prompt: true, 31 | email 32 | } 33 | }, 34 | convert_provider: async({ locals: { supabase } }) => { 35 | const { provider } = await getFormData('provider') 36 | 37 | if (!provider) { 38 | return fail(400, { 39 | error: 'Please pass a provider.' 40 | }) 41 | } 42 | 43 | const { data, error } = await supabase.auth.linkIdentity({ provider, options: { redirectTo: 'http:/localhost:5173/self' } }) 44 | 45 | if (error) 46 | return fail(error.status ?? 400, { error: error.message }) 47 | 48 | if (data.url) redirect(303, data.url) 49 | }, 50 | delete_nickname: async ({ locals: { supabase } }) => { 51 | const { error } = await supabase.auth.updateUser({ 52 | data: { nickname: null } 53 | }) 54 | 55 | if (error) 56 | return fail(error.status ?? 400, { error: error.message, nickname: '' }) 57 | 58 | /* Refresh tokens, so we can display the new nickname. */ 59 | await supabase.auth.refreshSession() 60 | }, 61 | delete_user: async() => { 62 | const { user } = await getFormData('user') 63 | 64 | if (!user) { 65 | return fail(400, { 66 | error: 'Please enter a user id.' 67 | }) 68 | } 69 | 70 | const supabase = createClient(PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) 71 | 72 | const { error } = await supabase.auth.admin.deleteUser(user) 73 | 74 | if (error) 75 | return fail(error.status ?? 400, { error: error.message }) 76 | 77 | return { message: 'User deleted.' } 78 | }, 79 | update_nickname: async ({ locals: { supabase } }) => { 80 | const { nickname } = await getFormData('nickname') 81 | 82 | if (!nickname) { 83 | return fail(400, 84 | { error: 'Please enter a nickname.' } 85 | ) 86 | } 87 | 88 | const { error } = await supabase.auth.updateUser({ 89 | data: { nickname } 90 | }) 91 | 92 | if (error) 93 | return fail(error.status ?? 400, { error: error.message, nickname }) 94 | 95 | /* Refresh tokens, so we can display the new nickname. */ 96 | await supabase.auth.refreshSession() 97 | }, 98 | update_password: async({ locals: { supabase } }) => { 99 | const { password } = await getFormData('password') 100 | 101 | if (!password) { 102 | return fail(400, { 103 | error: 'Please enter a new password' 104 | }) 105 | } 106 | 107 | const { error } = await supabase.auth.updateUser({ 108 | password 109 | }) 110 | 111 | if (error) 112 | return fail(error.status ?? 400, { error: error.message }) 113 | 114 | return { message: 'Password updated!' } 115 | }, 116 | update_phone: async ({ locals: { supabase } }) => { 117 | const { phone } = await getFormData('phone') 118 | 119 | if (!phone) { 120 | return fail(400, 121 | { error: 'Please enter a phone number.' } 122 | ) 123 | } 124 | 125 | /* Sends an OTP to phone number. */ 126 | const { error } = await supabase.auth.updateUser({ 127 | phone 128 | }) 129 | 130 | if (error) 131 | return fail(error.status ?? 400, { error: error.message }) 132 | 133 | return { message: 'Please check your phone for the OTP code and enter it below.' , verify: true, phone } 134 | }, 135 | verify_otp: async ({ locals: { supabase } }) => { 136 | /** 137 | * This action is used to update a phone number or 138 | * update an email address when converting an anonymous user. 139 | */ 140 | const { otp, phone, email, password } = await getFormData('otp', 'phone', 'email', 'password') 141 | 142 | if (!otp) { 143 | return fail(400, 144 | { message: 'Please enter an OTP.', verify: true, phone, email, password_prompt: password ? false : true } 145 | ) 146 | } 147 | 148 | if (phone) { 149 | const { error } = await supabase.auth.verifyOtp({ 150 | phone, 151 | type: 'phone_change', 152 | token: otp 153 | }) 154 | 155 | if (error) 156 | return fail(error.status ?? 400, { error: error.message, verify: true, phone }) 157 | 158 | } else if (email && password) { 159 | const { error } = await supabase.auth.verifyOtp({ 160 | email, 161 | type: 'email_change', 162 | token: otp 163 | }) 164 | 165 | if (error) 166 | return fail(400, { error: error.message, verify: true, email, password_prompt: true }) 167 | 168 | const { error: updateError } = await supabase.auth.updateUser({ 169 | password 170 | }) 171 | 172 | if (updateError) 173 | return fail(updateError.status ?? 400, { error: updateError.message, verify: true, email, password_prompt: true }) 174 | } else { 175 | return fail(400, { error: 'No phone or email/password received.'}) 176 | } 177 | 178 | return { message: 'Success!' , verify: false, password_prompt: false } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/self/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#if session} 16 |

Welcome to /self!

17 |

User Information:

18 |

ID: {session.user.id}

19 |

Email: {session.user.email || "not set"}

20 |

Phone Number: {session.user.phone || "not set"}

21 |

Nickname: {session.user.user_metadata.nickname || "not set"}

22 |
23 | Delete a user by ID: 24 | 25 | 26 |
27 |
28 | Change your nickname: 29 | 30 | 31 | 32 |
33 |
34 | Change your phone number: 35 | 36 | 37 |
38 | {#if has_email_provider} 39 |
40 | Change your password: 41 | 42 | 43 |
44 | {/if} 45 | {#if session.user.is_anonymous} 46 |
47 | Convert to a permanent user: 48 | 49 |
50 |
51 | Convert to a permanent user: 52 | 53 | 54 |
55 | {/if} 56 | {/if} 57 | 58 | {#if form?.message} 59 |

{form.message}

60 | {/if} 61 | {#if form?.error} 62 |

{form.error}

63 | {/if} 64 | {#if form?.verify} 65 |
66 | 67 | {#if form?.password_prompt} 68 | 69 | {/if} 70 | {#if form?.phone}{/if} 71 | {#if form?.email}{/if} 72 | 73 |
74 | {/if} 75 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const load = async ({ locals: { getSession }, cookies }) => { 2 | return { session: await getSession(), cookies: cookies.getAll() } 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 | 73 | 74 | {@render children?.()} 75 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public' 2 | import { getValidatedSession } from '$lib/utils.js' 3 | import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr' 4 | 5 | export const load = async ({ fetch, data, depends }) => { 6 | depends('supabase:auth') 7 | 8 | const supabase = isBrowser() 9 | ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { 10 | global: { fetch } 11 | }) 12 | : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { 13 | global: { fetch }, 14 | cookies: { 15 | getAll() { 16 | return data.cookies 17 | } 18 | } 19 | }) 20 | 21 | const session = isBrowser() ? await getValidatedSession(supabase) : data.session 22 | 23 | return { supabase, session } 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 |

HOME

2 |

👇 will redirect you to /auth if you aren't authenticated.

3 | App 4 | -------------------------------------------------------------------------------- /src/routes/auth/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from '@sveltejs/kit' 2 | import { type Provider } from '@supabase/supabase-js' 3 | import { getFormData } from '$lib/server/event.js' 4 | 5 | export const load = async ({ locals: { getSession } }) => { 6 | const session = await getSession() 7 | 8 | /* User is already logged in. */ 9 | if (session) redirect(303, '/app') 10 | } 11 | 12 | export const actions = { 13 | signup: async ({ locals: { supabase } }) => { 14 | const { email, password } = await getFormData('email', 'password') 15 | 16 | if (!email || !password) 17 | return fail(400, 18 | { error: 'Please enter an email and password', email } 19 | ) 20 | 21 | const { error } = await supabase.auth.signUp({ 22 | email, 23 | password 24 | }) 25 | 26 | if (error) 27 | return fail(error.status ?? 400, { error: error.message, email }) 28 | else 29 | return { message: 'Please check your email to confirm your signup.' } 30 | }, 31 | signin_email: async ({ locals: { supabase } }) => { 32 | const { email, password } = await getFormData('email', 'password') 33 | 34 | if (!email || !password) 35 | return fail(400, 36 | { error: 'Please enter an email and password', email } 37 | ) 38 | 39 | const { error } = await supabase.auth.signInWithPassword({ 40 | email, 41 | password 42 | }) 43 | 44 | if (error) 45 | return fail(error.status ?? 400, { error: error.message, email }) 46 | 47 | /* Login successful, redirect. */ 48 | redirect(303, '/app') 49 | }, 50 | signin_otp: async ({ locals: { supabase }}) => { 51 | const { phone } = await getFormData('phone') 52 | 53 | if (!phone) { 54 | return fail(400, 55 | { error: 'Please enter a phone number.' } 56 | ) 57 | } 58 | 59 | const { error } = await supabase.auth.signInWithOtp({ 60 | phone, 61 | }) 62 | 63 | if (error) 64 | return fail(error.status ?? 400, { error: error.message, phone }) 65 | 66 | return { message: 'Please check your phone for the OTP code and enter it below.' , verify: true, phone } 67 | 68 | }, 69 | oauth: async ({ url, locals: { supabase }}) => { 70 | const { provider } = await getFormData('provider') 71 | 72 | if (!provider) 73 | return fail(400, { error: 'No provider found.'}) 74 | 75 | /** 76 | * Sign-in will not happen yet, because we're on the server-side, 77 | * but we need the returned url. 78 | */ 79 | const { data, error } = await supabase.auth.signInWithOAuth({ 80 | provider, 81 | options: { 82 | redirectTo: `${url.origin}/auth/callback?next=/app` 83 | } 84 | }) 85 | 86 | if (error) 87 | return fail(error.status ?? 400, { error: error.message }) 88 | 89 | /* Now authorize sign-in on browser. */ 90 | if (data.url) redirect(303, data.url) 91 | }, 92 | magic: async ({ locals: { supabase }}) => { 93 | const { email } = await getFormData('email') 94 | 95 | if (!email) 96 | return fail(400, { error: 'Please enter an email.' }) 97 | 98 | const { error } = await supabase.auth.signInWithOtp({ 99 | email 100 | }) 101 | 102 | if (error) 103 | return fail(error.status ?? 400, { error: error.message }) 104 | else 105 | return { message: 'Please check your email to login.' } 106 | }, 107 | anon: async ({ locals: { supabase }}) => { 108 | const { error } = await supabase.auth.signInAnonymously() 109 | 110 | if (error) 111 | return fail(error.status ?? 400, { error: error.message }) 112 | 113 | /* Login successful, redirect. */ 114 | redirect(303, '/app') 115 | }, 116 | reset: async({ locals: { supabase } }) => { 117 | const { email } = await getFormData('email') 118 | 119 | if (!email) 120 | return fail(400, { error: 'Please enter an email.' }) 121 | 122 | const { error } = await supabase.auth.resetPasswordForEmail(email) 123 | 124 | if (error) 125 | return fail(error.status ?? 400, { error: error.message }) 126 | else 127 | return { message: 'Please check your email to reset your password.' } 128 | }, 129 | signout: async ({ locals: { supabase } }) => { 130 | await supabase.auth.signOut() 131 | redirect(303, '/') 132 | }, 133 | verify_otp: async ({ locals: { supabase } }) => { 134 | const { otp, phone } = await getFormData('otp', 'phone') 135 | 136 | if (!otp) { 137 | return fail(400, 138 | { error: 'Please enter an OTP.', verify: true, phone } 139 | ) 140 | } 141 | 142 | if (!phone) { 143 | return fail(400, 144 | { error: 'No phone number found.', verify: true } 145 | ) 146 | } 147 | 148 | const { error } = await supabase.auth.verifyOtp({ 149 | phone, 150 | type: 'sms', 151 | token: otp, 152 | options: { redirectTo: 'http://localhost:5173/app' } 153 | }) 154 | 155 | if (error) 156 | return fail(error.status ?? 400, { error: error.message, verify: true, phone }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/routes/auth/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 | 28 |
29 | 30 | {#if form?.message} 31 |

{form.message}

32 | {/if} 33 | {#if form?.error} 34 |

{form.error}

35 | {/if} 36 | {#if form?.verify} 37 |
38 | 39 | 40 | 41 |
42 | {/if} 43 | -------------------------------------------------------------------------------- /src/routes/auth/callback/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit' 2 | 3 | export const GET = async ({ url, locals: { supabase } }) => { 4 | const code = url.searchParams.get('code') as string 5 | const next = url.searchParams.get('next') ?? '/' 6 | 7 | if (code) { 8 | const { error } = await supabase.auth.exchangeCodeForSession(code) 9 | if (!error) { 10 | redirect(303, `/${next.slice(1)}`) 11 | } 12 | } 13 | 14 | /* Return the user to an error page with instructions */ 15 | redirect(303, '/') 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/auth/confirm/+server.ts: -------------------------------------------------------------------------------- 1 | import type { EmailOtpType } from '@supabase/supabase-js' 2 | import { redirect } from '@sveltejs/kit' 3 | 4 | export const GET = async ({ url, locals: { supabase } }) => { 5 | const token_hash = url.searchParams.get('token_hash') as string 6 | const type = url.searchParams.get('type') as EmailOtpType 7 | const next = url.searchParams.get('next') ?? '/' 8 | 9 | if (token_hash && type) { 10 | const { error } = await supabase.auth.verifyOtp({ token_hash, type }) 11 | if (!error) { 12 | redirect(303, `/${next.slice(1)}`) 13 | } 14 | } 15 | 16 | /* Return the user to an error page with some instructions */ 17 | redirect(303, '/') 18 | } 19 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j4w8n/sveltekit-supabase-ssr/7a23243dc2e37aefd9131025e883d42a4b93baa1/static/favicon.png -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the working 2 | # directory name when running `supabase init`. 3 | project_id = "sveltekit-supabase-ssr" 4 | 5 | [api] 6 | # Port to use for the API URL. 7 | port = 54321 8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 9 | # endpoints. public and storage are always included. 10 | schemas = ["public", "storage", "graphql_public"] 11 | # Extra schemas to add to the search_path of every request. public is always included. 12 | extra_search_path = ["public", "extensions"] 13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 14 | # for accidental or malicious requests. 15 | max_rows = 1000 16 | 17 | [db] 18 | # Port to use for the local database URL. 19 | port = 54322 20 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 21 | # server_version;` on the remote database to check. 22 | major_version = 15 23 | 24 | [studio] 25 | # Port to use for Supabase Studio. 26 | port = 54323 27 | 28 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 29 | # are monitored, and you can view the emails that would have been sent from the web interface. 30 | [inbucket] 31 | # Port to use for the email testing server web interface. 32 | port = 54324 33 | smtp_port = 54325 34 | pop3_port = 54326 35 | 36 | [storage] 37 | # The maximum file size allowed (e.g. "5MB", "500KB"). 38 | file_size_limit = "50MiB" 39 | 40 | [auth] 41 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 42 | # in emails. 43 | site_url = "http://localhost:3000" 44 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 45 | additional_redirect_urls = ["https://localhost:3000"] 46 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one 47 | # week). 48 | jwt_expiry = 3600 49 | # Allow/disallow new user signups to your project. 50 | enable_signup = true 51 | 52 | [auth.email] 53 | # Allow/disallow new user signups via email to your project. 54 | enable_signup = true 55 | # If enabled, a user will be required to confirm any email change on both the old, and new email 56 | # addresses. If disabled, only the new email is required to confirm. 57 | double_confirm_changes = true 58 | # If enabled, users need to confirm their email address before signing in. 59 | enable_confirmations = false 60 | 61 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 62 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, 63 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 64 | [auth.external.apple] 65 | enabled = false 66 | client_id = "" 67 | secret = "" 68 | # Overrides the default auth redirectUrl. 69 | redirect_uri = "" 70 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 71 | # or any other third-party OIDC providers. 72 | url = "" 73 | 74 | [analytics] 75 | enabled = false 76 | port = 54327 77 | vector_port = 54328 78 | # Setup BigQuery project to enable log viewer on local development stack. 79 | # See: https://supabase.com/docs/guides/getting-started/local-development#enabling-local-logging 80 | gcp_project_id = "" 81 | gcp_project_number = "" 82 | gcp_jwt_path = "supabase/gcloud.json" 83 | -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j4w8n/sveltekit-supabase-ssr/7a23243dc2e37aefd9131025e883d42a4b93baa1/supabase/seed.sql -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto' 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | } 17 | 18 | export default config 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite' 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }) 7 | --------------------------------------------------------------------------------