Skip to main content
Technology & EngineeringSvelte379 lines

Sveltekit Auth

Authentication patterns in SvelteKit using hooks, cookies, sessions, and OAuth flows

Quick Summary26 lines
You are an expert in implementing authentication in SvelteKit applications, covering session management, hooks-based auth guards, OAuth integration, and secure patterns for protecting routes and API endpoints.

## Key Points

1. User submits credentials via a form action or OAuth redirect.
2. Server validates credentials and creates a session (database record or signed token).
3. Session identifier is stored in an HTTP-only cookie.
4. The `handle` hook reads the cookie on every request, validates the session, and populates `event.locals`.
5. Load functions and actions access `event.locals` to check authentication.
- **Always use `httpOnly`, `secure`, and `sameSite` on session cookies.** This prevents XSS from stealing tokens and mitigates CSRF.
- **Validate sessions on every request in hooks**, not just in individual load functions. Centralized validation prevents gaps.
- **Hash passwords with bcrypt, scrypt, or argon2.** Never store plain-text passwords.
- **Implement session expiry and rotation.** Refresh session expiry on activity and invalidate old sessions.
- **Verify OAuth `state` parameter** to prevent CSRF attacks on the OAuth flow.
- **Never expose sensitive user data to the client.** Return only the fields the UI needs from load functions (no password hashes, internal IDs if unnecessary).
- **Use HTTPS in production.** The `secure` cookie flag requires it.

## Quick Example

```svelte
<form method="POST" action="/logout">
  <button>Log out</button>
</form>
```
skilldb get svelte-skills/Sveltekit AuthFull skill: 379 lines
Paste into your CLAUDE.md or agent config

Authentication — SvelteKit

You are an expert in implementing authentication in SvelteKit applications, covering session management, hooks-based auth guards, OAuth integration, and secure patterns for protecting routes and API endpoints.

Core Philosophy

Authentication in SvelteKit is built on web platform fundamentals: HTTP-only cookies carry session identifiers, the handle hook validates sessions on every request, and event.locals passes authentication state to load functions and actions. There is no framework-specific auth module because authentication requirements vary wildly between applications. Instead, SvelteKit provides the primitives — cookies, hooks, locals — and you compose them into the auth flow your application needs.

The handle hook is the security perimeter. Every server request passes through it before reaching any load function, action, or API endpoint. This makes it the single place to validate sessions, refresh expiring tokens, and populate the locals object with user data. If authentication checking is scattered across individual load functions and actions, it is inevitable that some endpoints will be left unprotected. Centralizing session validation in hooks and then checking locals.user in individual handlers is the pattern that prevents gaps.

Cookie security is not optional. Every session cookie must be httpOnly (preventing XSS from reading it), secure (requiring HTTPS), and sameSite: 'lax' (mitigating CSRF). SvelteKit's built-in CSRF protection via Origin header checking covers form submissions, but the cookie flags are your responsibility. Skipping any of these flags creates a real security vulnerability, not a theoretical one.

Anti-Patterns

  • Checking Auth Only in Load Functions — protecting pages but not form actions or API endpoints, allowing attackers to call POST /api/user directly. Every mutation endpoint must independently verify authentication.

  • Storing Tokens in localStorage — using localStorage for session tokens instead of HTTP-only cookies. Any JavaScript on the page (including XSS payloads) can read localStorage, making token theft trivial.

  • Forgetting path: '/' on Session Cookies — setting a session cookie without specifying path: '/', which scopes it to the current URL path. The cookie then is not sent on requests to other routes, breaking authentication silently.

  • Deleting Only the Cookie on Logout — removing the client-side cookie without invalidating the session record in the database. If the session ID is known, it can still be used until it expires naturally.

  • Redirecting with 302 After POST — using redirect(302, ...) after a form submission instead of redirect(303, ...). A 302 redirect preserves the HTTP method, which can cause the browser to re-POST to the redirect target.

Overview

SvelteKit provides a flexible foundation for authentication through its hooks system, cookies API, and locals object. There is no built-in auth module — instead, you compose these primitives (often with libraries like Lucia, Auth.js/NextAuth, or custom implementations) to build secure authentication flows tailored to your application.

Core Concepts

The Authentication Flow

  1. User submits credentials via a form action or OAuth redirect.
  2. Server validates credentials and creates a session (database record or signed token).
  3. Session identifier is stored in an HTTP-only cookie.
  4. The handle hook reads the cookie on every request, validates the session, and populates event.locals.
  5. Load functions and actions access event.locals to check authentication.

Server Hooks (hooks.server.js)

The handle function runs on every server request. It is the central place for session validation.

// src/hooks.server.js
import { db } from '$lib/server/database';

export async function handle({ event, resolve }) {
  const sessionId = event.cookies.get('session');

  if (sessionId) {
    const session = await db.getSession(sessionId);
    if (session && session.expiresAt > new Date()) {
      event.locals.user = session.user;
    } else {
      // Expired or invalid session — clear the cookie
      event.cookies.delete('session', { path: '/' });
    }
  }

  return resolve(event);
}

Cookie-Based Sessions

Set secure cookies when the user logs in:

// src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/database';
import { verifyPassword } from '$lib/server/auth';
import crypto from 'crypto';

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

    const user = await db.getUserByEmail(email);
    if (!user || !await verifyPassword(password, user.passwordHash)) {
      return fail(401, { email, error: 'Invalid email or password' });
    }

    const sessionId = crypto.randomUUID();
    const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days

    await db.createSession({ id: sessionId, userId: user.id, expiresAt });

    cookies.set('session', sessionId, {
      path: '/',
      httpOnly: true,
      sameSite: 'lax',
      secure: true,
      maxAge: 30 * 24 * 60 * 60 // 30 days in seconds
    });

    redirect(303, '/dashboard');
  }
};

Accessing Auth State in Load Functions

// src/routes/dashboard/+layout.server.js
import { redirect } from '@sveltejs/kit';

export function load({ locals }) {
  if (!locals.user) {
    redirect(303, '/login');
  }
  return {
    user: {
      id: locals.user.id,
      name: locals.user.name,
      email: locals.user.email
    }
  };
}

Protecting API Endpoints

// src/routes/api/user/+server.js
import { json, error } from '@sveltejs/kit';

export function GET({ locals }) {
  if (!locals.user) {
    error(401, 'Unauthorized');
  }
  return json(locals.user);
}

Implementation Patterns

Auth Guard with Hook Sequence

Protect entire route groups in the hook:

// src/hooks.server.js
const protectedPaths = ['/dashboard', '/settings', '/api/user'];

export async function handle({ event, resolve }) {
  // ... session validation (as above) ...

  const isProtected = protectedPaths.some(p =>
    event.url.pathname.startsWith(p)
  );

  if (isProtected && !event.locals.user) {
    if (event.url.pathname.startsWith('/api/')) {
      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
        status: 401,
        headers: { 'Content-Type': 'application/json' }
      });
    }
    return new Response(null, {
      status: 303,
      headers: { Location: `/login?redirect=${event.url.pathname}` }
    });
  }

  return resolve(event);
}

OAuth Flow (e.g., GitHub)

Step 1: Redirect to provider

// src/routes/login/github/+server.js
import { redirect } from '@sveltejs/kit';
import { GITHUB_CLIENT_ID } from '$env/static/private';

export function GET({ cookies }) {
  const state = crypto.randomUUID();
  cookies.set('oauth_state', state, {
    path: '/',
    httpOnly: true,
    maxAge: 600
  });

  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: 'https://myapp.com/login/github/callback',
    scope: 'read:user user:email',
    state
  });

  redirect(302, `https://github.com/login/oauth/authorize?${params}`);
}

Step 2: Handle callback

// src/routes/login/github/callback/+server.js
import { redirect, error } from '@sveltejs/kit';
import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '$env/static/private';

export async function GET({ url, cookies }) {
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');
  const storedState = cookies.get('oauth_state');

  if (!code || state !== storedState) {
    error(400, 'Invalid OAuth callback');
  }

  cookies.delete('oauth_state', { path: '/' });

  // Exchange code for token
  const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      code
    })
  });

  const { access_token } = await tokenRes.json();

  // Fetch user profile
  const userRes = await fetch('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${access_token}` }
  });
  const githubUser = await userRes.json();

  // Create or update user in database
  const user = await db.upsertUser({
    githubId: githubUser.id,
    name: githubUser.name,
    email: githubUser.email,
    avatar: githubUser.avatar_url
  });

  // Create session
  const sessionId = crypto.randomUUID();
  await db.createSession({ id: sessionId, userId: user.id });

  cookies.set('session', sessionId, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: true,
    maxAge: 30 * 24 * 60 * 60
  });

  redirect(303, '/dashboard');
}

Logout

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

export const actions = {
  default: async ({ cookies, locals }) => {
    const sessionId = cookies.get('session');
    if (sessionId) {
      await db.deleteSession(sessionId);
    }
    cookies.delete('session', { path: '/' });
    redirect(303, '/');
  }
};
<form method="POST" action="/logout">
  <button>Log out</button>
</form>

Role-Based Access

// src/lib/server/auth.js
export function requireRole(locals, role) {
  if (!locals.user) {
    error(401, 'Not authenticated');
  }
  if (locals.user.role !== role) {
    error(403, 'Forbidden');
  }
}
// src/routes/admin/+page.server.js
import { requireRole } from '$lib/server/auth';

export function load({ locals }) {
  requireRole(locals, 'admin');
  return { users: db.getAllUsers() };
}

Using Lucia Auth Library

Lucia is a popular auth library designed for SvelteKit:

// src/lib/server/lucia.js
import { Lucia } from 'lucia';
import { DrizzleSQLiteAdapter } from '@lucia-auth/adapter-drizzle';
import { db } from './database';
import { sessions, users } from './schema';

const adapter = new DrizzleSQLiteAdapter(db, sessions, users);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: { secure: process.env.NODE_ENV === 'production' }
  },
  getUserAttributes: (attributes) => ({
    email: attributes.email,
    name: attributes.name
  })
});
// src/hooks.server.js
import { lucia } from '$lib/server/lucia';

export async function handle({ 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;
  }
  return resolve(event);
}

Best Practices

  • Always use httpOnly, secure, and sameSite on session cookies. This prevents XSS from stealing tokens and mitigates CSRF.
  • Validate sessions on every request in hooks, not just in individual load functions. Centralized validation prevents gaps.
  • Hash passwords with bcrypt, scrypt, or argon2. Never store plain-text passwords.
  • Implement session expiry and rotation. Refresh session expiry on activity and invalidate old sessions.
  • Verify OAuth state parameter to prevent CSRF attacks on the OAuth flow.
  • Never expose sensitive user data to the client. Return only the fields the UI needs from load functions (no password hashes, internal IDs if unnecessary).
  • Use HTTPS in production. The secure cookie flag requires it.

Common Pitfalls

  • Checking auth only in load functions. An attacker can call form actions or API endpoints directly. Protect actions and +server.js handlers too.
  • Storing JWTs in localStorage. Use HTTP-only cookies instead. localStorage is accessible to any JavaScript on the page (XSS vulnerability).
  • Forgetting path: '/' on cookies. Without it, the cookie is scoped to the current path and may not be sent on other routes.
  • Redirecting with 302 after POST. Use redirect(303, ...) (See Other) after form submissions to ensure the browser makes a GET request to the redirect target.
  • Not invalidating server sessions on logout. Deleting only the cookie is insufficient if the session record remains valid in the database. Always delete both.
  • Shared locals across requests. event.locals is per-request, which is correct. But module-level variables in hooks are shared — do not store per-request auth state in module scope.

Install this skill directly: skilldb add svelte-skills

Get CLI access →