Captain Codeman Captain Codeman

Securing Your SvelteKit App

A simple but safe approach

Contents

Introduction

Whether your authorization logic consists of a simple “are you signed in?” or has more complex “do you have x permission on entity y?” checks it’s important to understand how SvelteKit works so you don’t accidentally introduce security issues or unnecessary slowdowns.

One of the most commented issues on the SvelteKit repo is a discussion about implementing security within a SvelteKit application and the potential risk from naive use of +layout.server.ts.

The TL;DR of the issue is that a developer who is new to SvelteKit might see the load function as the place to put authorization logic believing that because it is at the root of a collection of routes or the entire site it will run for every request and will protect all the routes and load functions underneath it. Of course, due to SvelteKit being flexible enough to cope with many different use-cases, it could be used to do this. But there are reasons you probably shouldn’t consider doing it. The real danger is that things may appear to work and your app appears to be secure, but it really isn’t, and to make it work securely mean sacrificing performance.

The implications for authentication is mentioned in the docs, but we’ll try to dive a little deeper to explain what is going on, the potential pitfalls someone could fall into, and what is a possible solution you can employ in your own apps.

But to be clear - we’re talking about protecting data on our server. If you’re fetching data directly on the client from someplace else, such as Firebase, then security is handled differently and you should be familiar with how to apply security rules on that platform so you don’t get featured in someone’s “I can’t believe how insecure this site is” video.

Requests begin with hooks.server

When a request hits SvelteKit on the server it will first go through the handle function defined in hooks.server.ts if one exists. This allows you to “hook” your own middleware into the request processing where you can do whatever you want before and after passing control on to SvelteKit which will decide which route to execute and render the output. You might check authentication cookies for instance and could decide to return an error if a user isn’t signed in, but you then need to be careful about blocking access to the login route if doing so. For anything but a trivial app with very simple authorization needs, it can quickly become unwieldly and confusing.

But we’ll revisit the handle function later to see how useful it can be as part of a security solution.

How do SvelteKit load Functions Work

The load functions are how you pass data in to SvelteKit route layouts and pages and you can define a universal function that can be executed on both the server and the client, or separate server-only and client-only load functions.

A universal load function would be inside a +layout.ts or +page.ts file and that load function can run on the server during Server Side Rendering (SSR) and also on the client during Client Side Routing (CSR). This would fetch some data and return it to the layout or page component:

export async function load({ fetch }) {
  const resp = await fetch('https://dummyjson.com/users')
  const users = await resp.json()

  return {
    users,
  }
}

Fun fact: on an initial page load the function runs first on the server and then runs again on the client and new SvelteKit users may think this means the data will be fetched twice, but SvelteKit optimizes things so the fetched data is embedded into the server-rendered page and returnd to the fetch function on the client so it is only actually requested once. Clever, eh?!

After the initial page render, the load function only executes on the client when a user navigates to the corresponding route OR if the user stays on the same route but a route parameter or URL parameter that the load function depends on is updated. This means you can trigger data fetches just by clicking a pagination link for example - SvelteKit does the work of loading the data for you. You can also trigger a reload programatically using an invalidate function. But whatever the reason for a universal load function to execute after the initial page load it will only ever execute on the client.

NOTE: This all assumes a default out-of-the-box SvelteKit setup with the node adapter. It is possible to disable SSR or CSR (to disable both, just don’t deploy it) and also have pages pre-rendered to static files, but if you’re doing that you won’t be authorizing any requests.

Some load functions can’t be run on the client though because they need to make calls to a server-side-only database, or the server file-system, or make use of credentials that need to be kept secure. For these a load function can be defined inside a +layout.server.ts or +page.server.ts file and this is almost identical to the universal load function except it also has access to some additional server-side properties such as the Request object and locals object that are passed along as SvelteKit processes the request. These allow your hooks.server handle function to pass information to the server-side load functions and for both to make decisions based on the request.

import { getUsers } from '$lib/database.server'

export async function load() {
  const users = await getUsers()

  return {
    users,
  }
}

This server-side load function will only execute on the server and if you also have a +page.ts or +layout.ts load function that will execute on both the server and the client as before. But now whenever a URL or route parameter changes, any server-side load function that depends on it will also be invoked, not only during the initial page SSR. The non-server-only load function can access and mutate the data returned from the server load fn, or pass it through unmodified - it could be where you apply a transform based on some client-side only data.

Aside: whenever you’re testing load functions, be sure to checkout the Link Options as the default is for SvelteKit to pre-fetch data when you hover over a link in order to speed up navigation - this may be confusing as the load functions may have already executed by the time you click in the browser.

We already have a few combination of where load functions execute and what can trigger them. But as well as the server and client execution there is also the routing hierarchy where a single request may involve multiple load functions along the route.

A simplistic approach is for every single route to load all of the data it requires, but this can be inefficient and unnecessarilly expensive in server resources and client bandwidth. Suppose for example that you have a site where you have a /project/[project-id] route that displays summary information about a project, but then has additional child routes under that to show /project/[project-id]/issues and /project/[project-id]/discussions. It would be inefficient to re-load the project information every time you navigated between each child route so you might decide to load this in a layout instead. If you used a load function in src/routes/project/[project]/+layout.server.ts that data would load once and be usable for all the page routes underneath it, only being re-triggered if the user navigated to a different project (or to another route and back).

Each child route only needs to load the data unique to it, whether that is the list of issues or list of discussion topics, so they can have their own load functions. Still with me? Hold on, this is a key bit …

What may not be immediately apparent (especially for those who refuse to read the docs) is that by default SvelteKit will optimize your data loading by running those load functions in parallel. So the request for the project data can happen in parallel to a request for the project issues.

TODO: demo code / video showing parallel loading

This is great - we get optimal performance and it’s all handled for us. But this is exactly where the “naive” thinking comes into play. Suppose we want to secure the project routes, and only people who have the “manager” or “developer” role should be able to access them. It might seem like you could just add a check to the +layout.server.ts load function to check the auth claims (probably using a user object set on locals by an auth handle function in hooks.server.ts) and while that would prevent the layout load function returning data, and could prevent the layout displaying a slot or even render an error page, it won’t prevent the child route load functions from executing and during CSR that data can be accessed. You don’t really have security.

TODO: demo code / video showing data being exposed

The mistake people make is thinking that +layout.server.ts is akin to middleware and somehow wraps everything under it when it doesn’t. I suspect this is because at the +layout.svelte component level it kind of is - the child components only render if the layout decides to give them a <slot /> to do so, but that is the UI component rendering tree, not the data loading which happens in parallel for performance. Each load fn is callable separately whether people do that through the apps web-ui or by hacking URLs, so each one needs to authorize the request in order to have real security.

While it’s understandable to want to just do the authorization check in one place the only way to force that to happen is to have the child load function(s) wait for the parent layout load function(s) to complete. That can be achieve by awaiting the parent event property in a child load function like this:

export async function load({ parent }) {
  await parent()
  // parent load function(s) will have now executed
  return {}
}

But there are two downsides to this:

  1. It introduces a waterfall. If each load fn takes 2 seconds, the user won’t see data for 4 seconds. The more load functions in the hierarchy, and the longer each takes, the worse the delay gets.
  2. It forces the parent load function to re-execute for each child load function.

TODO: demo code / video showing waterfall and repeated data loading

It effectivelty kills performance and destroys the nice caching that we had. The app will require more server resources to run, consume more bandwidth (for us and the user) and generally be slower and less responsive.

There are two solutions:

First, accept that you’re really back to loading all the data for each route within each route load function. However much you want to think you’re not, you effectively are once you await parent() (there are occasionally legitimate reasons to need to use it, which is why it’s there, but you shouldn’t do it in the name of “securateh!”). You can avoid code repition by moving things into importable functions and can introduce caching to help with performance but you’re just adding unnecessary complexity.

8qg68e

Second, embrace the SvelteKit approach as it was designed to work and accept that each load function is like an endpoint and all your endpoints need to be secure. It doesn’t have to be ugly.

This is the approach I use …

Add functions to authorize a request to the locals object (or create a separate security object that you add as a property on locals). This is done in the hooks.server.ts handle function where you’ll typically be decoding authorization cookies anyway. Because the locals object is available in all server-side load functions, you have access to these security functions without even having to import anything.

It can be as simple as a single function:

// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
  namespace App {
    // interface Error {}
    interface Locals {
      approve: () => void
    }
    // interface PageData {}
    // interface PageState {}
    // interface Platform {}
  }
}

export {}

The handle function in hooks.server can set this fn:

import { error } from '@sveltejs/kit'

export async function handle({ event, resolve }) {
  const { cookies, locals } = event

  const session = cookies.get('session')

  locals.approve = () => {
    if (!session) {
      error(401, 'no soup for you')
    }
  }

  return await resolve(event)
}

Now any endpoint that needs to be secured can simply call this function

export async function load({ locals }) {
  locals.approve()

  return {
    title: 'some data,
  }
}

The benefits to me is that we have security while keeping the parallel data loading and avoiding re-loading of parent / layout data when not required. We’re consuming fewer server resources, saving bandwidth, and creating a snappier app for the user. I’d also argue that having the security definitions together with the code they apply to is likely to help when reviewing code to ensure those rules are correct.

This is obviously very simplistic. How complex yours needs to be will depend on the nature of your app. Typically there will be some simple checks for whether someone is authenticated or not. Then some auth claim / role based checks - ideally, you want to authorize requests based on the auth token, without requiring any additional lookups. Others may require data checks for per-row permissions but it’s usually quite easy to add some caching in those cases.

More realistic example

Here’s a more complex example that should better reflect what a real app might look like. We’ll create a class that can provide multiple authorization methods which will trigger an appropriate http response in the event of a failed check. The security class is passed the RequestEvent which gives it access to any detail of the request, including the decoded User (if set).

import { error, type RequestEvent } from '@sveltejs/kit'
import type { User } from './firebase.server'
import type { Project } from './models'

export class Security {
  private readonly user?: User

  constructor(private readonly event: RequestEvent) {
    this.user = event.locals.user
  }

  isAuthenticated() {
    if (!this.user) {
      error(401, 'unauthorized')
    }
    return this
  }

  isAdmin() {
    if (!this.user?.admin) {
      error(403, 'not admin')
    }
    return this
  }

  hasRole(role: string) {
    if (!this.user?.roles.includes(role)) {
      error(403, 'missing role: ' + role)
    }
    return this
  }

  hasAnyRole(roles: string[]) {
    if (roles.some(role => this.user?.roles.includes(role))) {
      error(403, 'missing any role: ' + roles.join(', '))
    }
    return this
  }

  hasAllRoles(roles: string[]) {
    if (roles.every(role => this.user?.roles.includes(role))) {
      const missing = roles.filter(role => !this.user?.roles.includes(role)).join(', ')
      error(403, 'missing role(s): ' + missing)
    }
    return this
  }

  isProjectOwner(project: Project) {
    if (!this.user || !project.owners.includes(this.user.uid)) {
      error(403, 'not project owner')
    }
    return this
  }

  isInternalAccount() {
    if (!this.user || !this.user.email?.endsWith('@company.com')) {
      error(403, 'not internal account')
    }
    return this
  }
}

The locals object will have the user and an instance of the security class set, which we can define in app.d.ts:

import type { User } from '$lib/firebase.server'
import type { Security } from '$lib/security'

declare global {
  namespace App {
    // interface Error {}
    interface Locals {
      user?: User
      security: Security
    }
    // interface PageData {}
    // interface PageState {}
    // interface Platform {}
  }
}

export {}

This imagines using Firebase for auth with server-side cookies. We set the locals.user property to a decoded * verified User instance, and add the locals.security property to an instance of the Security class defined earlier:

import { auth, type User } from '$lib/firebase.server'
import { Security } from '$lib/security'

export async function handle({ event, resolve }) {
  const { cookies, locals } = event

  const session = cookies.get('session')

  if (session) {
    try {
      locals.user = (await auth.verifySessionCookie(session)) as User
    } catch (err: any) {
      switch (err.code) {
        case 'auth/session-cookie-revoked':
          cookies.delete('session', { path: '/' })
          break
        default:
          console.error(err)
      }
    }
  }

  locals.security = new Security(event)

  return await resolve(event)
}

Now, within our server-side load functions, we can call whatever checks are needed for that particular fn from the locals.security property that is passed in - no need to even import anything. The methods are chainable so mutiple checks are easy.

import { getProject } from '$lib/firebase.server'

export async function load({ locals: { security }, params: { id } }) {
  const project = await getProject(id)

  security.isInternalAccount().isProjectOwner(project)

  return {
    project,
  }
}

We can also use these checks within any REST endpoints

Idea for Enhancement

What if you forget to call any of the security methods in a server load function? Oh noes!

To protect against this you could set a flag in the security class whenever a check has been made, then check if any request with the event.isDataRequest set to true has been made without the flag being set and output a warning (at least in dev mode) or throw an exception (rather than risk accidentally exposing some data).

Let me know if you think of any other enhancements.

If you found this useful, you may want to checkout the svelte-api-keys project where I used a variation on this idea with the addition of performing rate-limiting checks - it’s more geared toward protecting SvelteKit REST APIs but uses a similar approach.