Svelte logo

SvelteKit by Example: Cookies & Sessions

Cookies let the browser remember a session between requests. The cookie holds an opaque token; the server decides which user it belongs to.

In this example, we'll sign in, protect our todos, and sign out.

Use the session-checking hook from the hooks and locals example. These routes need a server adapter, not this site's static adapter. Disable prerendering with export const prerender = false in their +page.js files.

These examples use a few async helpers from your own src/lib/server/auth.js module. They're not built into SvelteKit:

  • verifyCredentials(email, password) uses a trusted password-hashing library to verify a stored password hash, returning { id, name } or null. Don't store plaintext passwords or use a fast general-purpose hash for passwords.
  • createSession(userId) generates a new cryptographically random, unpredictable opaque token, persists its hash, user ID, and expiry, and returns { token, expiresAt }, where expiresAt is a Date.
  • getUserFromSession(token) hashes the token, looks up the session, checks expiry and revocation, and returns the user or null.
  • revokeSession(token) revokes the persisted session; a missing or unknown token is harmless.

Read the form on the server. Check that both fields are strings and nonempty before verifying credentials.

Return a generic error for invalid credentials. Never return the password to the page.

A new session remembers the verified user. The cookie expiry matches the persisted session expiry.

httpOnly blocks JavaScript access. sameSite limits cross-site sending; secure requires HTTPS outside local development.

Update this request's locals too. Redirect to a fixed internal path; redirects throw, so keep them outside a try/catch.

src/routes/login/+page.server.js
import { dev } from '$app/env';
import { fail, redirect } from '@sveltejs/kit';
import { createSession, verifyCredentials } from '#lib/server/auth.js';

/** @type {import('./$types').Actions} */
export const actions = {
  default: async ({ request, cookies, locals }) => {
    const fields = await request.formData();
    const email = fields.get('email');
    const password = fields.get('password');

    if (typeof email !== 'string' || !email.trim() ||
        typeof password !== 'string' || !password) {
      return fail(400, { message: 'Enter your email and password.' });
    }



    const user = await verifyCredentials(email.trim(), password);
    if (!user) {
      return fail(400, { message: 'Invalid email or password.' });
    }




    const session = await createSession(user.id);
    cookies.set('session', session.token, {
      path: '/',
      expires: session.expiresAt,




      httpOnly: true,
      sameSite: 'lax',
      secure: !dev,
    });





    locals.user = user;
    redirect(303, '/todos');
  },
};

A normal POST works without JavaScript. The action reads the inputs by their name attributes.

Show the action's validation message through form.

src/routes/login/+page.svelte
<script>
  /** @type {import('./$types').PageProps} */
  let { form } = $props();
</script>

<form method="POST">
  <label>Email <input name="email" type="email" autocomplete="username" required></label>
  <label>Password <input name="password" type="password" autocomplete="current-password" required></label>
  <button>Sign in</button>
</form>



{#if form?.message}
  <p>{form.message}</p>
{/if}

Protect reads as well as writes. Our database helper getTodosForUser(id) returns only that user's todos.

POST actions need their own check. A layout guard or a hidden button doesn't protect an action.

deleteTodoForUser(id, userId) must delete only where both the todo ID and owner match, returning whether a row was deleted.

src/routes/todos/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { getTodosForUser, deleteTodoForUser } from '#lib/server/db.js';

/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
  if (!locals.user) redirect(303, '/login');
  return { todos: await getTodosForUser(locals.user.id) };
}



/** @type {import('./$types').Actions} */
export const actions = {
  delete: async ({ request, locals }) => {
    if (!locals.user) redirect(303, '/login');
    const fields = await request.formData();
    const id = fields.get('id');

    if (typeof id !== 'string' || !id.trim()) {
      return fail(400, { message: 'Choose a todo.' });
    }




    const deleted = await deleteTodoForUser(id, locals.user.id);
    if (!deleted) return fail(404, { message: 'Todo not found.' });
    return { message: 'Todo deleted!' };
  },
};

Each delete form sends a todo ID, not a user ID. The server gets the owner from the verified session.

Sign out with a POST, not a link that changes session state on GET.

src/routes/todos/+page.svelte
<script>
  /** @type {import('./$types').PageProps} */
  let { data, form } = $props();
</script>

{#each data.todos as todo (todo.id)}
  <form method="POST" action="?/delete">
    <span>{todo.task}</span>
    <input type="hidden" name="id" value={todo.id}>
    <button>Delete</button>
  </form>
{/each}

{#if form?.message}<p>{form.message}</p>{/if}


<form method="POST" action="/logout">
  <button>Sign out</button>
</form>

Revoke the server session before removing the cookie. Deleting only the cookie leaves a stolen token usable.

Delete with the same path used when setting the cookie. Clear locals, then redirect to the login page.

src/routes/logout/+page.server.js
import { redirect } from '@sveltejs/kit';
import { revokeSession } from '#lib/server/auth.js';

/** @type {import('./$types').Actions} */
export const actions = {
  default: async ({ cookies, locals }) => {
    await revokeSession(cookies.get('session'));





    cookies.delete('session', { path: '/' });
    locals.user = null;
    redirect(303, '/login');
  },
};

Give the logout route a page component too. Visiting it only shows a form; the POST performs the logout.

src/routes/logout/+page.svelte
<form method="POST">
  <button>Sign out</button>
</form>

Before publishing

Keep SvelteKit's origin checks for form POSTs enabled, serve production over HTTPS, and rate-limit login attempts. httpOnly doesn't prevent all attacks: a session token is a credential, so never expose it in page data or logs.