3 min read
On this page

Session Management

Sessions connect a browser to a server-side identity. When a user logs in, you create a session, store a reference to it in a cookie, and use that cookie on subsequent requests to identify who is making the request. SvelteKit provides a clean cookies API through event.cookies that handles the complexity of secure cookie management.

The Cookies API

SvelteKit's event.cookies provides typed methods for managing cookies:

// src/routes/api/example/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ cookies }) => {
  // Read a cookie
  const session = cookies.get('session');

  // Set a cookie with options
  cookies.set('session', 'abc123', {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7 // 7 days
  });

  // Delete a cookie
  cookies.delete('session', { path: '/' });

  return new Response('ok');
};

Unlike raw Set-Cookie headers, event.cookies handles serialization, parsing, and ensures you set cookies correctly. The path option is required when setting or deleting cookies because SvelteKit does not assume a default.

Every session cookie should use these security options:

// src/lib/server/session.ts
import type { Cookies } from '@sveltejs/kit';

const SESSION_COOKIE = 'session';

export function setSessionCookie(cookies: Cookies, token: string) {
  cookies.set(SESSION_COOKIE, token, {
    path: '/',        // Cookie sent for all routes
    httpOnly: true,   // JavaScript cannot read it (prevents XSS theft)
    secure: true,     // Only sent over HTTPS
    sameSite: 'lax',  // Sent with top-level navigations, not cross-site requests
    maxAge: 60 * 60 * 24 * 7  // Expires in 7 days
  });
}

export function clearSessionCookie(cookies: Cookies) {
  cookies.delete(SESSION_COOKIE, { path: '/' });
}

export function getSessionToken(cookies: Cookies): string | undefined {
  return cookies.get(SESSION_COOKIE);
}

HttpOnly prevents client-side JavaScript from reading the cookie. This stops XSS attacks from stealing sessions. Secure ensures the cookie is only sent over HTTPS. SameSite: lax allows the cookie on same-site requests and top-level navigations (clicking a link) but blocks it on cross-site POST requests, which mitigates CSRF attacks.

Server-Side Sessions

Server-side sessions store the session data on the server and only put an opaque token in the cookie. This is the more secure approach because the client never sees the actual session data:

// src/lib/server/session.ts
import { randomBytes } from 'crypto';

interface SessionData {
  userId: string;
  createdAt: number;
  expiresAt: number;
}

// In production, use Redis, a database, or Cloudflare KV
const sessions = new Map<string, SessionData>();

export function createSession(userId: string): string {
  const token = randomBytes(32).toString('hex');
  const now = Date.now();

  sessions.set(token, {
    userId,
    createdAt: now,
    expiresAt: now + 7 * 24 * 60 * 60 * 1000 // 7 days
  });

  return token;
}

export function getSession(token: string): SessionData | null {
  const session = sessions.get(token);
  if (!session) return null;
  if (Date.now() > session.expiresAt) {
    sessions.delete(token);
    return null;
  }
  return session;
}

export function destroySession(token: string): void {
  sessions.delete(token);
}
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { getSession } from '$lib/server/session';
import { getUserById } from '$lib/server/db';

export const handle: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('session');

  if (token) {
    const session = getSession(token);
    if (session) {
      event.locals.user = await getUserById(session.userId);
    } else {
      event.cookies.delete('session', { path: '/' });
      event.locals.user = null;
    }
  } else {
    event.locals.user = null;
  }

  return resolve(event);
};

JWT Sessions

JWTs encode the session data directly in the token. No server-side storage is needed. The trade-off is that you cannot revoke individual sessions without maintaining a blocklist:

// src/lib/server/jwt.ts
import { SignJWT, jwtVerify } from 'jose';
import { JWT_SECRET } from '$env/static/private';

const secret = new TextEncoder().encode(JWT_SECRET);

export async function createToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret);
}

export async function verifyToken(token: string): Promise<{
  userId: string;
  role: string;
} | null> {
  try {
    const { payload } = await jwtVerify(token, secret);
    return {
      userId: payload.userId as string,
      role: payload.role as string
    };
  } catch {
    return null;
  }
}
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { verifyToken } from '$lib/server/jwt';
import { getUserById } from '$lib/server/db';

export const handle: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('session');

  if (token) {
    const payload = await verifyToken(token);
    if (payload) {
      event.locals.user = await getUserById(payload.userId);
    } else {
      event.cookies.delete('session', { path: '/' });
      event.locals.user = null;
    }
  } else {
    event.locals.user = null;
  }

  return resolve(event);
};

The Login Flow

A complete login implementation using form actions:

// src/routes/login/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { verifyPassword } from '$lib/server/auth';
import { createSession } from '$lib/server/session';
import { setSessionCookie } from '$lib/server/session';

export const load: PageServerLoad = async ({ locals }) => {
  // Already logged in — redirect to app
  if (locals.user) {
    redirect(303, '/app');
  }
};

export const actions: Actions = {
  default: async ({ request, cookies, url }) => {
    const formData = await request.formData();
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    if (!email || !password) {
      return fail(400, {
        email,
        error: 'Email and password are required'
      });
    }

    const user = await verifyPassword(email, password);

    if (!user) {
      return fail(400, {
        email,
        error: 'Invalid email or password'
      });
    }

    // Create session and set cookie
    const token = createSession(user.id);
    setSessionCookie(cookies, token);

    // Redirect to the return URL or default to /app
    const returnTo = url.searchParams.get('returnTo') || '/app';
    redirect(303, returnTo);
  }
};
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  import type { ActionData } from './$types';
  import { enhance } from '$app/forms';

  let { form }: { form: ActionData } = $props();
</script>

<h1>Log In</h1>

{#if form?.error}
  <p class="error">{form.error}</p>
{/if}

<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>

  <button type="submit">Log In</button>
</form>

The Logout Flow

Logout destroys the session and clears the cookie:

// src/routes/logout/+page.server.ts
import type { Actions } from './$types';
import { redirect } from '@sveltejs/kit';
import { destroySession, clearSessionCookie, getSessionToken } from '$lib/server/session';

export const actions: Actions = {
  default: async ({ cookies }) => {
    const token = getSessionToken(cookies);
    if (token) {
      destroySession(token);
    }
    clearSessionCookie(cookies);
    redirect(303, '/');
  }
};
<!-- Logout button used in navigation -->
<form method="POST" action="/logout">
  <button type="submit">Log Out</button>
</form>

Using a POST action for logout prevents CSRF issues. A GET request to /logout could be triggered by an image tag or link prefetch.

Integrating with Auth Providers

Modern applications often delegate authentication to a dedicated provider. The pattern is similar regardless of the provider: redirect to the provider, receive a callback with tokens, create a local session.

Lucia (self-hosted auth library):

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { lucia } from '$lib/server/lucia';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get(lucia.sessionCookieName);

  if (sessionId) {
    const { session, user } = await lucia.validateSession(sessionId);

    if (session?.fresh) {
      const cookie = lucia.createSessionCookie(session.id);
      event.cookies.set(cookie.name, cookie.value, {
        path: '/',
        ...cookie.attributes
      });
    }

    if (!session) {
      const cookie = lucia.createBlankSessionCookie();
      event.cookies.set(cookie.name, cookie.value, {
        path: '/',
        ...cookie.attributes
      });
    }

    event.locals.user = user;
    event.locals.session = session;
  } else {
    event.locals.user = null;
    event.locals.session = null;
  }

  return resolve(event);
};

Supabase Auth:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_KEY } from '$env/static/private';

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.supabase = createServerClient(
    PUBLIC_SUPABASE_URL,
    SUPABASE_SERVICE_KEY,
    {
      cookies: {
        getAll: () => event.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => {
            event.cookies.set(name, value, { ...options, path: '/' });
          });
        }
      }
    }
  );

  const { data: { user } } = await event.locals.supabase.auth.getUser();
  event.locals.user = user;

  return resolve(event);
};

Reading Sessions in Load Functions

The session data flows from hooks to load functions through locals:

// src/routes/app/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';

export const load: LayoutServerLoad = async ({ locals }) => {
  if (!locals.user) {
    redirect(303, '/login');
  }

  return {
    user: {
      id: locals.user.id,
      name: locals.user.name,
      email: locals.user.email
    }
  };
};

Common Pitfalls

  • Storing sensitive data in cookies directly. Never put user roles, permissions, or personal data in a cookie value. Use an opaque session token and store the data server-side.
  • Forgetting the path option. SvelteKit requires you to specify path when setting or deleting cookies. Without it, the operation fails silently or applies to the wrong path.
  • Not setting HttpOnly. Without httpOnly: true, client-side JavaScript can read the session cookie. An XSS vulnerability becomes a session hijacking vulnerability.
  • Using GET for logout. A GET request to /logout can be triggered by link prefetching, image tags, or CSRF attacks. Always use a POST form action for logout.
  • Not refreshing expiring sessions. If a user is active but their session expires, they are logged out mid-task. Extend the session on activity by resetting maxAge or creating a new session periodically.
  • In-memory session storage in serverless. The Map examples above work for adapter-node but not for serverless functions that may not share memory across invocations. Use a database or external store.

Key Takeaways

  • Use event.cookies.get/set/delete for all cookie operations. Always specify the path option.
  • Session cookies should be httpOnly, secure, and sameSite: 'lax' at minimum.
  • Server-side sessions (opaque token in cookie, data in database) are more secure than JWTs because you can revoke them instantly.
  • JWTs avoid server-side storage but cannot be revoked without a blocklist.
  • The pattern is consistent: set session cookie in the login action, read it in the handle hook, clear it in the logout action.
  • Auth providers like Lucia and Supabase Auth follow the same pattern but handle token management and user storage for you.
  • Always use POST form actions for logout to prevent CSRF-triggered logouts.