Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 094 / 100

Headers and security in SvelteKit in Svelte & SvelteKit

Secure responses by default.

Expona Admin8 min read

By day 94, you've built real features in SvelteKit, but security often gets overlooked until a production incident reminds us why it matters. Headers and security in SvelteKit establish the first line of defense for every response we send, making response security the foundation of a robust application rather than an afterthought.

Content Security Policy via kit.csp

Content Security Policy (CSP) is a browser security mechanism that restricts where resources can be loaded from. In SvelteKit, we configure it through kit.csp in svelte.config.js, and it's one of the most effective ways to prevent XSS attacks.

Imagine we're building an e-commerce platform where user reviews can include rich content. Without CSP, a malicious user could inject a script tag that steals customer credit card data. With CSP in place, the browser simply refuses to execute any inline scripts that aren't explicitly whitelisted.

Here's how to set it up:

typescript
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';

export default {
  kit: {
    adapter: adapter(),
    csp: {
      directives: {
        'default-src': ['self'],
        'script-src': ['self', 'https://cdn.example.com'],
        'style-src': ['self', 'unsafe-inline'],
        'img-src': ['self', 'data:', 'https:'],
        'font-src': ['self'],
        'connect-src': ['self', 'https://api.example.com'],
        'frame-ancestors': ['none'],
        'base-uri': ['self'],
        'form-action': ['self']
      },
      reportUri: '/api/csp-report'
    }
  }
};

SvelteKit automatically injects nonce tokens into inline scripts it generates, so your own Svelte components work without unsafe-inline. The reportUri endpoint lets you log CSP violations, which is invaluable for catching injection attempts in production.

For development, you can set mode: 'hash' to hash inline scripts instead of using nonces, which can be simpler locally but is less secure:

typescript
csp: {
  mode: 'hash',
  directives: {
    'script-src': ['self']
  }
}

Security headers in hooks

Beyond CSP, we need to set additional HTTP security headers on every response. This is where SvelteKit's hooks system shines. The handle hook in src/hooks.server.ts lets us add headers to every response uniformly.

Consider a SaaS application handling sensitive client data. We want to prevent clickjacking attacks, disable MIME type sniffing, enforce HTTPS, and tell browsers not to cache sensitive pages. These are all accomplished via headers:

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

export const handle: Handle = async ({ event, resolve }) => {
  const response = await resolve(event);

  // Prevent clickjacking attacks
  response.headers.set('X-Frame-Options', 'DENY');

  // Prevent MIME type sniffing
  response.headers.set('X-Content-Type-Options', 'nosniff');

  // Enable browser XSS protection
  response.headers.set('X-XSS-Protection', '1; mode=block');

  // Enforce HTTPS (only in production)
  if (!event.url.hostname.includes('localhost')) {
    response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
  }

  // Restrict referrer information
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  // Control feature access
  response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');

  return response;
};

Each of these headers solves a specific attack vector. X-Frame-Options: DENY prevents your app from being embedded in an iframe on another domain. Strict-Transport-Security forces browsers to always use HTTPS, even if a user manually types HTTP. The preload flag lets you register your domain with browser vendors to have HTTPS enforced before the first visit.

You can also conditionally set headers based on the request. For instance, skip caching headers for API endpoints but enforce them for user-facing pages:

typescript
// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
  const response = await resolve(event);

  // No caching for API routes
  if (event.url.pathname.startsWith('/api/')) {
    response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate');
  } else if (event.url.pathname.startsWith('/admin/')) {
    // Sensitive admin pages: no caching at all
    response.headers.set('Cache-Control', 'no-store');
  }

  // ... other security headers

  return response;
};

Strict cookies

Cookies are a prime target for session hijacking and CSRF attacks. By default, cookies can be accessed from JavaScript and sent over insecure connections. We need to tighten this.

In a trading platform where users execute real transactions, a poorly configured session cookie could allow an attacker to perform unauthorized trades. SvelteKit makes this explicit through server-side cookie configuration:

typescript
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';

export const handle: Handle = async ({ event, resolve }) => {
  // Set session cookie with strict settings
  event.cookies.set('session_token', 'your-token-here', {
    path: '/',
    httpOnly: true,        // Never accessible from JavaScript
    secure: !dev,          // HTTPS only in production
    sameSite: 'strict',    // Only sent in same-site requests
    maxAge: 60 * 60 * 24 * 7  // 7 days
  });

  const response = await resolve(event);
  return response;
};

httpOnly: true is non-negotiable. It prevents XSS attacks from stealing the cookie. secure: true ensures cookies are only sent over HTTPS. sameSite: 'strict' prevents CSRF by refusing to send the cookie on cross-site requests.

For reading cookies in your routes, use the event.cookies object, which is server-only:

typescript
// src/routes/profile/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ cookies, locals }) => {
  const sessionToken = cookies.get('session_token');

  if (!sessionToken) {
    throw error(401, 'Not authenticated');
  }

  // Verify token, fetch user data, etc.
  return {
    user: locals.user
  };
};

Validate all inputs on the server

Client-side validation is convenient for UX, but it's not security. A determined attacker bypasses your frontend entirely and sends raw HTTP requests. The real validation must always happen on the server.

We've seen this in ride-hailing platforms where a rider's app version might be out of date, or a curl request could bypass all client checks. Consider a form that accepts a promo code:

typescript
// src/routes/checkout/+page.server.ts
import type { Actions } from './$types';
import { fail, error } from '@sveltejs/kit';
import { z } from 'zod';

const promoSchema = z.object({
  promoCode: z.string().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid characters'),
  quantity: z.number().int().min(1).max(1000),
  email: z.string().email()
});

export const actions: Actions = {
  applyPromo: async ({ request, locals }) => {
    if (request.method !== 'POST') {
      throw error(405, 'Method not allowed');
    }

    const data = await request.formData();
    const input = {
      promoCode: data.get('promoCode'),
      quantity: parseInt(data.get('quantity')),
      email: data.get('email')
    };

    // Parse and validate
    const validation = promoSchema.safeParse(input);
    if (!validation.success) {
      return fail(400, {
        error: 'Invalid input',
        errors: validation.error.flatten().fieldErrors
      });
    }

    // Check user authentication
    if (!locals.user) {
      throw error(401, 'Not authenticated');
    }

    const { promoCode, quantity, email } = validation.data;

    // Query promo from database (never trust client data)
    const promo = await db.promos.findUnique({ where: { code: promoCode } });
    if (!promo || !promo.isActive) {
      return fail(400, { error: 'Promo code not found or expired' });
    }

    // Check authorization: email matches user
    if (email !== locals.user.email) {
      throw error(403, 'Email does not match your account');
    }

    // Apply promo logic
    const discount = promo.discount * quantity;

    return { success: true, discount };
  }
};

Notice we:

  • Parse and validate every field against a schema (Zod, Valibot, or similar)
  • Check authentication status explicitly
  • Verify authorization (does this user own this resource?)
  • Query the database again to confirm the promo exists and is active
  • Never trust a user's claim about what they're authorized to do

This is especially critical for financial operations. A lax developer might check a discount value from the form data directly, letting a user set any discount they want. Always re-query and re-verify on the server.

Common mistakes

  • Disabling CSP because "it's too strict": CSP warnings during development are a feature, not a bug. You're finding XSS vulnerabilities before production. Fix them by whitelisting specific domains or using nonce tokens, not by dropping CSP.
  • Setting cookies without httpOnly: This defeats the entire point of cookies for sensitive data like session tokens. Always set httpOnly: true for authentication cookies.
  • Only validating on the client: Frontend validation is for UX. Backend validation is for security. Do both, but rely only on the backend.
  • Forgetting Strict-Transport-Security in production: Without HSTS, a user's first request might still use HTTP, allowing a man-in-the-middle attack. Set it early and commit to HTTPS forever.
  • Logging sensitive data: Don't log credit card numbers, passwords, or tokens, even in development. They can leak through log aggregation services.

Key takeaways

  • Configure CSP in svelte.config.js to prevent XSS. SvelteKit handles nonce injection automatically for your components.
  • Set security headers in the handle hook to protect every response. Focus on X-Frame-Options, X-Content-Type-Options, and Strict-Transport-Security.
  • Always set cookies with httpOnly: true, secure: true in production, and sameSite: 'strict' or 'lax' for session tokens.
  • Validate all inputs on the server using a schema library. Client validation improves UX but never replaces server validation.
  • Check authentication and authorization explicitly in every server action. Assume the client is untrusted.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles