Authentication is an essential part of any application that makes certain functions available only to certain users. The two main methods for authentication in web applications are cookies and tokens (mostly JSON Web Tokens (JWTs)). In this tutorial, we will create a Svelte app using SvelteKit that implements a cookie for authentication.
The app provides the basic functions you need related to authentication: sign in, sign up, sign out, and access to some user data in the frontend. This is what the app will look like:
And here’s our table of contents:
- Introduction to SvelteKit
- Implementing authentication in Svelte with SvelteKit
- Securing routes and accessing the session on the client
First of all, let’s start with some basics before getting to the fun part.
Introduction of SvelteKit
One last thing before we are getting started with actual coding. What is SvelteKit? How is it different from Svelte?
You could think of SvelteKit being for Svelte what Next.js is for React. It is a framework on top of a framework — a meta-framework.
SvelteKit is a framework for building web applications of all sizes, with a beautiful development experience and flexible file system–based routing.
SvelteKit extends Svelte with some functionality that we will use in this tutorial: file system–based routing, endpoints (server-side functions), and hooks.
Implementing authentication in Svelte with SvelteKit
Okay, now let’s go ahead and build this. All the code is also available on GitHub.
Setup
First of all, we initialize the SvelteKit project. For this tutorial, we’ll go ahead with JavaScript instead of TypeScript:
npm init [email protected] sveltekit-auth # ✔ Which Svelte app template? › Skeleton project # ✔ Use TypeScript? … No # ✔ Add ESLint for code linting? … Yes # ✔ Add Prettier for code formatting? … Yes cd sveltekit-auth npm install
Let’s add Tailwind for some basic styling. We also use the Tailwind forms plugin, which provides some basic styling for our forms:
npx [email protected] tailwindcss npm i @tailwindcss/forms
In order to use the Tailwind forms plugin, we have to add it to the plugins
in the tailwind.config.cjs
, which was created by the svelte-add
command:
// tailwind.config.cjs const config = { mode: 'jit', purge: ['./src/**/*.{html,js,svelte,ts}'], theme: { extend: {}, }, plugins: [ require('@tailwindcss/forms') ], }; module.exports = config;
That’s it for the very basic setup. Let’s build the UI next before we get to the actual authentication.
Build the UI
Let us create the forms for signing up and signing in first. Create /src/lib/components/SignInForm.svelte
:
// src/lib/components/SignInForm.svelte <script> import Input from '$lib/components/Input.svelte'; import Button from '$lib/components/Button.svelte'; import { createEventDispatcher } from 'svelte'; let email = ''; let password = ''; const dispatch = createEventDispatcher(); function submit() { dispatch('submit', { email, password }) } </script> <form on:submit|preventDefault={submit} class='space-y-5 {$$props.class}'> <Input label='Email' id='email' name='email' type='email' bind:value={email} required /> <Input label='Password' id='password' name='password' type='password' bind:value={password} required /> <Button type='submit'>Sign In</Button> </form>
Here we have a form with an email and password input. The component dispatches a submit
event when the user submits the form. With {$$props.class}
in the form
’s class
attribute, we allow passing in Tailwind classes from outside. I use this mainly for positioning a component from the outside. The component itself should not have a margin
or something similar on its container.
N.B., you can find the code for Button
and Input
in the GitHub repo.
And it is basically the same for SignUpForm.svelte
:
// src/lib/components/SignUpForm.svelte <script> import Input from '$lib/components/Input.svelte'; import Button from '$lib/components/Button.svelte'; import { createEventDispatcher } from 'svelte'; let email = ''; let password = ''; let confirmPassword = ''; let error; let confirmPasswordInputRef; const dispatch = createEventDispatcher(); function submit() { error = null; if (password !== confirmPassword) { error = "Passwords do not match."; confirmPasswordInputRef.focus(); return; } dispatch('submit', { email, password }) } </script> <form on:submit|preventDefault={submit} class='space-y-5 {$$props.class}'> <Input label='Email' id='email' name='email' type='email' bind:value={email} /> <Input label='Password' id='password' name='password' type='password' bind:value={password} /> <Input label='Confirm Password' id='confirm-password' name='confirm-password' type='password' bind:value={confirmPassword} bind:inputRef={confirmPasswordInputRef} /> {#if error} <p class='text-red-600 text-sm font-semibold'>{error}</p> {/if} <Button type='submit'>Sign Up</Button> </form>
In this case, we have an additional input in order to verify that the user entered the intended password. If the user entered different passwords, we show an error and set the focus back to the input.
Okay, let’s now use these forms on actual pages. You can create a route using SvelteKit by creating a .svelte
file in the src/routes
folder. The name of the file will match the route that will be created. We create our /sign-up
route by creating src/routes/sign-up.svelte
:
// src/routes/sign-up.svelte <script> import SignUpForm from '$lib/components/SignUpForm.svelte'; let error; async function handleSubmit({detail: {email, password}}) { const response = await fetch('/api/sign-up', { method: 'POST', body: JSON.stringify({email, password}), headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { error = (await response.json()).message; return; } window.location = '/protected'; } </script> <h1 class='text-2xl font-semibold text-center'>Sign Up</h1> {#if error} <p class='mt-3 text-red-500 text-center font-semibold'>{error}</p> {/if} <SignUpForm class='max-w-xl mx-auto mt-8' on:submit={handleSubmit}/>
Here, we use our SignUpForm
and handle the dispatched submit
event. If the user submits the form, we send a POST
request containing the email
and the password
in the body to /api/sign-up
, which we’ll create in a bit. If the server responds with a success status (2xx
) we’ll navigate the user to the /protected
route, which we’ll also create later. Otherwise, we render an error.
N.B., SvelteKit provides its own function for client-side navigation: goto
. But in this case, it didn’t work for me. The user would have to refresh the page in order to be logged in. A simple window.location = '/protected' does its job here.
The /sign-in
looks exactly the same with the only differences being the used form and the endpoint where we send the request:
// src/routes/sign-in.svelte <script> import SignInForm from '$lib/components/SignInForm.svelte'; let error; async function handleSubmit({detail: {email, password}}) { const response = await fetch('/api/sign-in', { method: 'POST', body: JSON.stringify({ email, password }), headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { error = (await response.json()).message; return; } window.location = '/protected'; } </script> <h1 class='text-2xl font-semibold text-center'>Sign In</h1> {#if error} <p class='mt-3 text-red-500 text-center font-semibold'>{error}</p> {/if} <SignInForm class='max-w-xl mx-auto mt-8' on:submit={handleSubmit} />
There are also a layout component, an index page, and a navigation component, which I won’t go into detail about here. They are not required to understand how to implement authentication in Svelte. You can look them up in the GitHub repo.
Let’s create the endpoints for the authentication.
Authentication endpoints
A user has to sign up first. Therefore, we’ll go ahead and create the /sign-up
endpoint where we send a request when a user submits the sign-up form. You have to create a .js
(or .ts
) file in src/routes
in order to create an endpoint in SvelteKit. We create our JavaScript files in the api
subfolder, which creates routes beginning with /api/
. First of all, we need two additional libraries for our endpoints:
npm i cookie uuid
Let me introduce you to our in-memory database for storing the user data and the sessions:
// src/routes/api/_db.js import { v4 as uuidv4 } from 'uuid'; const users = [ { email: '[email protected]', // ⚠️ CAUTION: Do not store a plain password like this. Use proper hashing and salting. password: 'thisisnotsecret', }, ]; let sessions = []; export const getUserByEmail = async (email) => { const existingUser = users.find((user) => user.email === email); if (!existingUser) return Promise.resolve(null); return Promise.resolve(existingUser); }; export const registerUser = (user) => { const existingUser = users.find((u) => u.email === user.email); if (!!existingUser) return Promise.reject(new Error('User already exists')); users.push(user); return Promise.resolve(user); }; export const createSession = (email) => { const session = { id: uuidv4(), email, }; sessions.push(session); return Promise.resolve(session); }; export const getSession = (id) => { const session = sessions.find((session) => session.id === id); if (!session) return Promise.resolve(null); return Promise.resolve(session); }; export const removeSession = (id) => { const session = sessions.find((session) => session.id === id); if (!session) return Promise.reject(new Error('Session not found')); sessions = sessions.filter((session) => session.id !== id); return Promise.resolve(session); };
Notice the filename which is prefixed with _
, which indicates that this is not an endpoint but a normal JS file.
The users
and sessions
arrays act as our in-memory databases. The data will get lost if the application is restarted. This is fine for this tutorial, but in the real world, you would connect to a real database or use a SaaS like Supabase.
All the other functions in this file just act as an API for our data in users
and sessions
. We store the user data in users
and the session data consisting of the session_id
and email
in sessions
.
Now let’s get to our first actual endpoint:
// src/routes/api/sign-up.js import { createSession, getUserByEmail, registerUser } from './_db'; import { serialize } from 'cookie'; /** @type {import('@sveltejs/kit').RequestHandler} */ export async function post({ body: { email, password } }) { const user = await getUserByEmail(email); if (user) { return { status: 409, body: { message: 'User already exists', }, }; } // ⚠️ CAUTION: Do not store a plain password like this. Use proper hashing and salting. await registerUser({ email, password, }); const { id } = await createSession(email); return { status: 201, headers: { 'Set-Cookie': serialize('session_id', id, { path: '/', httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24 * 7, // one week }), }, body: { message: 'Successfully signed up', }, }; }
By exporting a function named post
, SvelteKit will use this function for post requests (you could also export get
, etc.). We check if a user with the given email already exists. If that’s not the case, we do register the new user and create a session.
The interesting and important part happens in the headers section of the response. We set the actual cookie containing the session_id
. That ID will be sent automatically by the client with the subsequent requests. We can look up the session ID in our sessions
“database.”
This is a quick overview of the cookie options we pass to the serialize
function, which returns the string to describe the cookie. You can read about details on MDN’s Using HTTP cookies:
path
: defines for which paths the cookie will be sent alongside the request. By setting the path to/
, the cookie will be sent alongside each request (also our requests to/api/**
)httpOnly
: prevents JS in the client to access that cookie.document.cookie
will not contain that cookie. This is a security setting and should be your default. Without setting it totrue
, malicious JS (also from browser extensions) could read thatsession_id
and send it somewhere and be logged in with your sessionsameSite
=strict
sets theSame-Site
attribute in the cookie. By setting it tostrict
, the cookie is only sent to the site where it originated. It prevents CSRF.secure
is another security feature. By settingsecure = true
, the client will only send the cookie alongside the request ifhttps://
is used. It prevents an attacker from using a man-in-the-middle attack reading the cookie while being sentmax-age
sets theMax-Age
attribute in the cookie. If the specified duration is over (one week in our example), the client (= the browser) will not send the cookie alongside the request anymore and deletes it. This makes the cookie a “permanent” cookie rather than a “session” cookie. Remember the common Keep me logged in checkbox? By enabling that checkbox, the server will set theMax-Age
(orExpires
) attribute in the cookie. The user will keep the session and therefore stay logged in for one week
Sidenote ℹ️: It is questionable whether sending the customer the information that an email is already in use is a good idea. A potential attacker could use the information about existing email addresses. Nevertheless, companies like Twitter and Facebook are also sending the information to the client (not saying that these should be the gold standard for data privacy and security).
Okay, with understanding how the sign-up works, the sign-in and sign-out are easy to understand. This is how the /api/sign-in
handler looks:
// src/routes/api/sign-in.js import { createSession, getUserByEmail } from './_db'; import { serialize } from 'cookie'; /** @type {import('@sveltejs/kit').RequestHandler} */ export async function post({ body: { email, password } }) { const user = await getUserByEmail(email); // ⚠️ CAUTION: Do not store a plain passwords. Use proper hashing and salting. if (!user || user.password !== password) { return { status: 401, body: { message: 'Incorrect user or password', }, }; } const { id } = await createSession(email); return { status: 200, headers: { 'Set-Cookie': serialize('session_id', id, { path: '/', httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24 * 7, // one week }), }, body: { message: 'Successfully signed in', }, }; }
It is essentially the same, but this time we only look up an existing user rather than also creating a new one. We again create a session and send the cookie containing the session_id
to the client.
In the /api/sign-out
, handler we use a GET
request because the client doesn’t have to actively send any data (remember, the cookie will be sent automatically). We remove the session from our in-memory database and remove the cookie by unsettling the value and setting an immediate expiration date:
// src/routes/api/sign-out.js import { removeSession } from './_db'; import { parse, serialize } from 'cookie'; /** @type {import('@sveltejs/kit').RequestHandler} */ export async function get({ headers: { cookie } }) { const cookies = parse(cookie || ''); if (cookies.session_id) { await removeSession(cookies.session_id); } return { status: 200, headers: { 'Set-Cookie': serialize('session_id', '', { path: '/', expires: new Date(0), }), }, }; }
That is it for our endpoints. But how is the client able to access any session data? We did not send anything other than the session_id
to the client. That is what we will be looking at next.
Svelte hooks
Hooks are a special thing in SvelteKit. Hooks run on the server and allow us to extend the behavior of SvelteKit.
The handle hook runs on every request (and during prerendering). It gives us access to the request and allows us to modify the response. We can add custom data to request.locals
, which will be available in all endpoints. We will use it to parse the session_id
cookie, retrieve the session, and attach the session data to request.locals
.
But that doesn’t make the session accessible by the client. This is where another hook comes into play: getSession
. Whatever we return from getSession
will be available in a session Svelte store in the frontend. Make sure to not return sensitive data (like the password) here.
You could add authorization functionality by adding something like a permissions
array to the user
object returned from getSession
. You could check these permissions in the frontend and allow the user only to do certain things based on the permissions.
This is how we implement the hooks:
// src/hooks.js import { parse } from 'cookie'; import { getSession as getSessionFromApi } from './routes/api/_db'; /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ request, resolve }) { const cookies = parse(request.headers.cookie || ''); if (cookies.session_id) { const session = await getSessionFromApi(cookies.session_id); if (session) { request.locals.user = { email: session.email }; return resolve(request); } } request.locals.user = null; return resolve(request); } /** @type {import('@sveltejs/kit').GetSession} */ export function getSession(request) { return request?.locals?.user ? { user: { email: request.locals.user.email, }, } : {}; }
This way, the user
object containing the users’ email will be accessible in the frontend. That is what we will be looking at next.
More great articles from LogRocket:
- Don't miss a moment with The Replay, a curated newsletter from LogRocket
- Learn how LogRocket's Galileo cuts through the noise to proactively resolve issues in your app
- Use React's useEffect to optimize your application's performance
- Switch between multiple versions of Node
- Discover how to animate your React app with AnimXYZ
- Explore Tauri, a new framework for building binaries
- Compare NestJS vs. Express.js
Securing routes and accessing the session on the client
Back to the frontend. Let us now use the user
object in the session. We now create another route that will only be accessible by authenticated users.
Pages and layouts have access to a special method called load
. The method has to be written in the <script context="module">
block since it runs before the component is rendered. It runs on the client and on the server during server-side rendering. The load
function gives us access to the session
(and several other things we don’t need here).
We can check if the session contains the user. If that’s not the case, the user isn’t signed in. We can redirect the user by returning the combination of HTTP status code 302
(Found) and a redirect
pointing to the route where the user should be redirected. Because the load
function is running before the actual rendering of the page, an unauthenticated user will never see the page. (You can try it by navigating to /protected
in the finished demo.)
By returning the user
in the props
object from the load
function, we can access user
as a prop in the component instance. There is an alternative way to access user
in the session since the load
function is only available on pages and layouts. You can access the session via the session store provided by SvelteKit. (This is used in the Navigation component).
This is how a protected route looks:
// src/routes/protected.svelte <script context="module"> export async function load({ session }) { if (!session?.user) { return { status: 302, redirect: "/sign-in" } } return { props: { user: session.user } }; } </script> <script> export let user; // import { session } from '$app/stores'; // $session.user; </script> <h1 class='text-2xl font-semibold text-center'>Hi! You are registered with email {user.email}.</h1>
That is it. We checked all the boxes and have an app with working authentication.
Conclusion
SvelteKit helps a lot by providing us the tools we need to create a nice user flow for authentication. We can easily create endpoints for the logic; hooks for parsing, checking, and providing the session; and in the frontend, we can access the session data either in the load
function or through the provided session
store. You have full control and can easily extend and change the functionality.
Make sure to play around with the demo (GitHub repo). Or — even better — try to recreate it yourself.
Cut through the noise of traditional error reporting with LogRocket

LogRocket is a digital experience analytics solution that shields you from the hundreds of false-positive errors alerts to just a few truly important items. LogRocket tells you the most impactful bugs and UX issues actually impacting users in your applications.
Then, use session replay with deep technical telemetry to see exactly what the user saw and what caused the problem, as if you were looking over their shoulder.
LogRocket automatically aggregates client side errors, JS exceptions, frontend performance metrics, and user interactions. Then LogRocket uses machine learning to tell you which problems are affecting the most users and provides the context you need to fix it.
Focus on the bugs that matter — try LogRocket today.
Any luck getting any of the salting/hashing libraries ie bcrypt or argon2 with Sveltekit and vercel? works in dev but can’t get any hashing library working in prod with the adapter.
Are they ESM modules? I have problem using CommonJS modules like ‘pg’ with Vercel or Netlify but the adapter for Node.js works for me. Another option might be to let the database handle the encryption?
Hi Jonathan,
I personally haven’t tried it with vercel. You can use NodeJS built-in crypto module for salting and hashing. There is another LogRocket blog post about password hashing: https://blog.logrocket.com/building-a-password-hasher-in-node-js/
I’m not that good in JS and such yet but why are you using Promise.. in your unctions? You’re using a feww awaits but not promsies for checking if something is resolved, pending or such. Coudln’t you jsut replace them with true/false instead?
Can you point me to an exact place in the code that isn’t clear to you?
In general await is just syntactic sugar in JS. It allows you to write your code like it would be synchronous. With await you are literally awaiting the promise. It can be used instead of .then(). And it allows you to surround it with try-catch instead of using .catch()
In src/routes/api/_db.js I just add promise to mimic asynchronous calls to a DB or other services.
goto() won’t work either for me. Another solution would be to set the session via ‘$app/stores’ right after successfully logged in, then you can call goto function. User object should matched the same one loaded from getSession btw. What do you think about this approach?
That might work.
I plan on updating the repo to the latest SvelteKit version in a bit and I’ll have a look at that redirect part again. I’ll give an update here if it’s done.
In my app, I found that goto works but does not cause the session to be reevaluated. I ended up doing that manually in my app
const rs = await fetch(“api/auth”, { method: “POST”, body, headers });
if (rs.ok) {
const user = await rs.json();
// Load user info into session.
session.set({ authenticated: true, user });
goto(“/”);
}
For anyone having issues with this try “await goto” to have a matching session!
If you return actual session data fomr the endpoint and write the response to the session store you can use goto. It feels a little redundant since you now set the session in the hook and after the response, but it does work and enables you to use goto() for navigation:
https://github.com/JannikWempe/svelte-auth/commit/7732fa8b168f30a9aeca87aa679c42d33ca2fc15
Hi, thanks for a nice tutorial. I noticed one more problem with session.set in handleSignOut. It causes rerun of the load function in a protected page or layout. My current workaround is to set session variable after goto(‘/sign-in’) navigation. More details in a link below.
https://github.com/sveltejs/kit/issues/2252
https://kit.svelte.dev/docs#loading-input