SvelteKit auth with HttpOnly cookies
Aug 12, 2026 Authentication
An HttpOnly cookie is one your browser stores and sends with every request, but JavaScript cannot read it. That last part is why it beats localStorage for session tokens: if someone gets a script running on your page, localStorage.getItem('token') hands over the token, while document.cookie will not show them an HttpOnly cookie at all.
Below is a login flow built on that: a login form, a session cookie set by the server, a protected area, and logout. It needs a server at runtime, so you want adapter-node, adapter-vercel, or something similar. With adapter-static there is no server to set the cookie, and in that case read protected routes in SPA mode instead.
We will end up with these files:
src/
├── app.d.ts
├── hooks.server.ts
├── lib
│ └── server
│ └── session.ts
└── routes
├── (app)
│ ├── +layout.server.ts
│ └── dashboard
│ └── +page.svelte
├── login
│ ├── +page.server.ts
│ └── +page.svelte
└── logout
└── +page.server.ts Table of Contents
Step 1: store sessions on the server
The cookie should hold a random session id, not user data. The server keeps the actual session and looks it up on each request. That way you can log someone out by deleting one row, and nobody can tamper with their own identity by editing a cookie value.
Create src/lib/server/session.ts:
import { randomUUID } from 'node:crypto';
export type User = { id: string; email: string };
type Session = { user: User; expiresAt: number };
const sessions = new Map<string, Session>();
const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
export function createSession(user: User): string {
const id = randomUUID();
sessions.set(id, { user, expiresAt: Date.now() + MAX_AGE_SECONDS * 1000 });
return id;
}
export function getSessionUser(id: string): User | null {
const session = sessions.get(id);
if (!session) {
return null;
}
if (session.expiresAt < Date.now()) {
sessions.delete(id);
return null;
}
return session.user;
}
export function deleteSession(id: string) {
sessions.delete(id);
}
export { MAX_AGE_SECONDS }; Anything under $lib/server is server only. If a component ever imports it by accident, SvelteKit fails the build instead of shipping your session code to the browser.
The Map keeps this example short. It empties on every server restart and it does not work if you run more than one instance, so swap it for a sessions table in your database (columns: id, user_id, expires_at) once the flow works. The three function signatures stay the same.
Step 2: tell TypeScript about the logged in user
Every request will carry the user on event.locals. Declare that in src/app.d.ts:
import type { User } from '$lib/server/session';
declare global {
namespace App {
interface Locals {
user: User | null;
}
}
}
export {}; Step 3: read the cookie on every request
src/hooks.server.ts runs before any load function or action, which makes it the right place for work that every request needs. If hooks are new to you, the short version is that handle wraps every request. Read the session cookie there once, and the rest of the app just checks locals.user.
import type { Handle } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/session';
export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session');
event.locals.user = sessionId ? getSessionUser(sessionId) : null;
return resolve(event);
}; locals is created fresh for each request and never leaves the server, so it is safe to trust inside load functions and actions.
Step 4: set the cookie when the user logs in
src/routes/login/+page.server.ts handles the form submission:
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { createSession, MAX_AGE_SECONDS } from '$lib/server/session';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, cookies, url }) => {
const data = await request.formData();
const email = String(data.get('email') ?? '');
const password = String(data.get('password') ?? '');
// your own lookup: find the user, compare the password against a hash
const user = await verifyUser(email, password);
if (!user) {
return fail(400, { email, message: 'Invalid email or password' });
}
const sessionId = createSession(user);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
secure: !dev,
sameSite: 'lax',
maxAge: MAX_AGE_SECONDS
});
const redirectTo = url.searchParams.get('redirectTo');
redirect(303, redirectTo?.startsWith('/') ? redirectTo : '/dashboard');
}
}; Those five cookie options carry most of the security, so it is worth knowing what each one does:
path: '/'sends the cookie on every route. SvelteKit makes you set this explicitly.httpOnly: truehides the cookie from JavaScript, which is the whole reason for doing it this way.secure: !devkeeps the cookie on HTTPS only. Tying it todevmeans it still works onhttp://localhost.sameSite: 'lax'tells the browser not to send the cookie on cross site POST requests, which blocks most CSRF attempts. Use'strict'if you never need the cookie on inbound links from other sites.maxAgeis in seconds. Match it to your server side expiry so the cookie does not outlive the session it points to.
Two traps live in that action. redirect() works by throwing, so never call it inside a try block that swallows errors. And the redirectTo?.startsWith('/') check is doing real work: without it, someone can send a user to /login?redirectTo=https://evil.example and your own app performs the redirect for them.
Password checking is its own topic. Store a hash from a real password hashing algorithm such as argon2 or bcrypt, never the password itself, and compare against the hash inside verifyUser.
Step 5: the login page
src/routes/login/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" use:enhance>
<label>
Email
<input type="email" name="email" value={form?.email ?? ''} required />
</label>
<label>
Password
<input type="password" name="password" required />
</label>
{#if form?.message}
<p style="color: red">{form.message}</p>
{/if}
<button type="submit">Login</button>
</form> There is no fetch call here and no token for you to store anywhere. The form posts to the action from step 4, and the browser saves the cookie that comes back. Adding use:enhance turns the submit into a client side request so the page does not fully reload, and the form still works if JavaScript is disabled.
SvelteKit also checks the origin header on form POST requests by default and rejects cross site submissions, so you get CSRF protection here without extra work.
Step 6: protect the private routes
Put everything that needs a login inside a route group and guard the group once. src/routes/(app)/+layout.server.ts:
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) {
redirect(303, `/login?redirectTo=${url.pathname}`);
}
return { user: locals.user };
}; Unlike the client side version of this check, this one is real protection. The load function runs on the server, so an unauthenticated visitor never receives the page or the data behind it.
Because the layout returns user, every page in the group can read it:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Dashboard</h1>
<p>Signed in as {data.user.email}</p> Server load functions still run on client side navigations, so the check repeats on every visit to a protected page.
Step 7: log out
Logging out means clearing both halves: the server session and the cookie. src/routes/logout/+page.server.ts:
import { redirect } from '@sveltejs/kit';
import { deleteSession } from '$lib/server/session';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ cookies }) => {
const sessionId = cookies.get('session');
if (sessionId) {
deleteSession(sessionId);
}
cookies.delete('session', { path: '/' });
redirect(303, '/login');
}
}; Use a form, not a link, so the logout is a POST and gets the same CSRF protection as the login:
<form method="POST" action="/logout">
<button type="submit">Logout</button>
</form> Deleting only the cookie is a common mistake. The session would stay valid on the server, and anyone who copied the cookie value could keep using it.
Note that cookies.delete() needs the same path you set the cookie with. A mismatch leaves the old cookie in place.
Things that trip people up
Setting a cookie in a load function that runs during prerendering fails, because there is no live request to attach it to. Set cookies in actions or in hooks.server.ts.
Reading document.cookie for the session returns nothing, and that is the feature working. If you need to know in the browser whether someone is signed in, pass a flag through the layout data rather than reaching for the cookie.
Checking locals.user inside +page.ts instead of +page.server.ts does not work either. locals only exists on the server.
What to add next
Before any of this goes near production, move the sessions out of that Map and into a database table, rotate the session id when someone logs in, and hash passwords with argon2 or bcrypt.