Serving uploaded files behind auth in SvelteKit

Aug 15, 2026 SvelteKit

The upload post built a +server.ts handler that returns any file in uploads/ if you know the name. That works for a public gallery. An invoice or an ID photo needs a session check on that GET, because the <img> is a second request and a layout load never sees it. SvelteKit layouts do not wrap +server.ts, so the check lives in the handler.

This uses those upload files and the session from HttpOnly cookie auth. It needs a server at runtime, so you want the same adapter as the upload. adapter-static cannot do this.

We will end up with these files:

src/
├── hooks.server.ts              # already from the auth post
├── lib/server/uploads.ts        # who owns which file
└── routes/upload/
    ├── +page.svelte             # unchanged
    ├── +page.server.ts          # list only this user's files
    └── [name]/
        └── +server.ts           # session + owner, then the bytes

Why a UUID is not enough

A UUID looks unguessable until someone forwards the tab. Slack fetches the image to build a preview. A reverse proxy logs the path, and the browser may send the URL as Referer to a third-party script. Treat the name as an id.

Step 1: refuse anonymous GET

hooks.server.ts already resolved the session cookie into locals.user before this handler runs. An <img src="/upload/..."> on the same origin sends that cookie on its own. You do not pass a token from JavaScript.

Update src/routes/upload/[name]/+server.ts:

import { error } from '@sveltejs/kit';
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import type { RequestHandler } from './$types';

const ALLOWED: Record<string, string> = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp'
};

export const GET: RequestHandler = async ({ params, locals }) => {
  if (!locals.user) {
    error(401, 'Sign in');
  }

  const name = params.name;
  const type = ALLOWED[extname(name)];

  if (!type || name.includes('..') || name.includes('/') || name.includes('\')) {
    error(404, 'Not found');
  }

  try {
    const bytes = await readFile(join('uploads', name));
    return new Response(bytes, {
      headers: {
        'content-type': type,
        'x-content-type-options': 'nosniff',
        'cache-control': 'private, no-store'
      }
    });
  } catch {
    error(404, 'Not found');
  }
};

Do not redirect to /login from this handler. The browser would follow it and try to paint the login HTML as the image. error(401) is what the file request should return. Send people to login from the page load, where a redirect is a page navigation.

cache-control: private, no-store tells the browser and any proxy not to keep a copy. private alone is not enough, because a shared computer could still show the image from cache after logout. If the files are large and you later want caching, you need validators (an ETag) and a short max-age, and you must re-check the session on every revalidation. Start with no-store.

Do not put a public CDN in front of this route. Many of them cache 200 responses unless you configure them not to, and then user B gets user A’s file without ever hitting your handler.

Step 2: check the owner, not just the session

After step 1, any signed-in user can fetch any file if they have the name. Store who uploaded it, and compare that to locals.user.id.

Create src/lib/server/uploads.ts. Anything under $lib/server stays on the server. If a component imports it by accident, the build fails instead of shipping the manifest to the browser.

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

export const UPLOAD_DIR = 'uploads';
const MANIFEST = join(UPLOAD_DIR, 'manifest.json');

export type UploadRecord = {
  name: string;
  ownerId: string;
  createdAt: number;
};

async function readManifest(): Promise<UploadRecord[]> {
  try {
    return JSON.parse(await readFile(MANIFEST, 'utf8'));
  } catch {
    return [];
  }
}

async function writeManifest(records: UploadRecord[]) {
  await mkdir(UPLOAD_DIR, { recursive: true });
  await writeFile(MANIFEST, JSON.stringify(records));
}

export async function recordUpload(record: UploadRecord) {
  const records = await readManifest();
  records.push(record);
  await writeManifest(records);
}

export async function getUpload(name: string): Promise<UploadRecord | null> {
  const records = await readManifest();
  return records.find((r) => r.name === name) ?? null;
}

export async function listUploadsFor(ownerId: string): Promise<UploadRecord[]> {
  const records = await readManifest();
  return records.filter((r) => r.ownerId === ownerId);
}

The JSON file keeps this example short. Two uploads at the same instant can overwrite each other. Swap the three functions for a table (name, owner_id, created_at) once this works. The GET handler does not change.

Treat a file with no row as missing, so leftover files from before you added ownership stay unreadable.

Now record the owner in the action, and check it in GET. Replace src/routes/upload/+page.server.ts:

import { fail, redirect } from '@sveltejs/kit';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { listUploadsFor, recordUpload, UPLOAD_DIR } from '$lib/server/uploads';
import type { Actions, PageServerLoad } from './$types';

const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED: Record<string, string> = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp'
};

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

  const records = await listUploadsFor(locals.user.id);
  return { files: records.map((r) => r.name) };
};

export const actions: Actions = {
  default: async ({ request, locals }) => {
    if (!locals.user) {
      return fail(401, { message: 'Sign in to upload' });
    }

    const data = await request.formData();
    const file = data.get('file');

    if (!(file instanceof File) || file.size === 0) {
      return fail(400, { message: 'Choose a file' });
    }

    if (file.size > MAX_BYTES) {
      return fail(400, { message: 'File must be 2 MB or smaller' });
    }

    const ext = extname(file.name).toLowerCase();
    const type = ALLOWED[ext];
    if (!type || file.type !== type) {
      return fail(400, { message: 'Only JPEG, PNG or WebP images' });
    }

    const name = `${crypto.randomUUID()}${ext}`;
    await mkdir(UPLOAD_DIR, { recursive: true });
    await writeFile(join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer()));
    await recordUpload({ name, ownerId: locals.user.id, createdAt: Date.now() });

    return { success: true };
  }
};

And add the owner check to the GET handler, next to the session check:

import { getUpload, UPLOAD_DIR } from '$lib/server/uploads';

// inside GET, after the extension / path checks:
const record = await getUpload(name);
if (!record || record.ownerId !== locals.user.id) {
  error(404, 'Not found');
}

const bytes = await readFile(join(UPLOAD_DIR, name));

Return 404 when the file is missing and when it belongs to someone else. A 403 that says “not your file” confirms the id exists. The page load already filtered the list, so a 404 here is a stale bookmark or someone trying ids.

+page.svelte does not change. It only renders data.files, and load now returns this user’s names.

Here is the finished GET handler:

import { error } from '@sveltejs/kit';
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { getUpload, UPLOAD_DIR } from '$lib/server/uploads';
import type { RequestHandler } from './$types';

const ALLOWED: Record<string, string> = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp'
};

export const GET: RequestHandler = async ({ params, locals }) => {
  if (!locals.user) {
    error(401, 'Sign in');
  }

  const name = params.name;
  const type = ALLOWED[extname(name)];

  if (!type || name.includes('..') || name.includes('/') || name.includes('\')) {
    error(404, 'Not found');
  }

  const record = await getUpload(name);
  if (!record || record.ownerId !== locals.user.id) {
    error(404, 'Not found');
  }

  try {
    const bytes = await readFile(join(UPLOAD_DIR, name));
    return new Response(bytes, {
      headers: {
        'content-type': type,
        'x-content-type-options': 'nosniff',
        'cache-control': 'private, no-store'
      }
    });
  } catch {
    error(404, 'Not found');
  }
};

ALLOWED still rejects manifest.json. If you ever serve every file in the folder, that file is a list of every upload and every owner id.

Try it

  1. Sign in, upload an image, copy the /upload/<id>.jpg URL.
  2. Open the URL in a private window. You should get 401 and a broken image, not the file.
  3. Sign in as a second user and open the same URL. You should get 404.
  4. Sign in as the original user. The image loads again.

If step 2 still shows the image, you are looking at a cached response from before the headers changed, or the file is being served from static/ instead of this handler.

Why an img tag works here

The HttpOnly cookie is sent on same-origin requests, including <img>. JavaScript cannot read it, but the browser still attaches it. Cookie auth works with file URLs because of that. A localStorage token never reaches the image request.

<img src="..."> has no way to set an Authorization header. If your SPA keeps a Bearer token in JavaScript, the image request goes out naked. The workaround is fetch with the header, then URL.createObjectURL on the blob. You lose the ability to open the file in a new tab as a normal URL.

SameSite=Lax (what the auth post set) also stops another site from embedding your private image. A cross-site <img> is a subresource request, so the browser withholds the cookie and your handler returns 401.

Things that trip people up

Guarding the upload form and leaving GET open means anyone with the URL still gets the file.

Putting /upload inside a route group whose +layout.server.ts checks locals.user only protects pages. That layout does not run for +server.ts.

redirect(303, '/login') in the file handler fills the image slot with HTML.

Listing with readdir puts other people’s ids in the HTML, even if GET later returns 404.

Saving into static/ sends those files out with the rest of the site, with no session and no owner check.

A 403 that says the file exists tells the caller the id is real. Use 404.

Cache-Control: public, max-age=31536000 because “images should be cached forever” lets a CDN hand the same bytes to the next visitor.

What to add next

The bytes are still on the app disk. On Vercel that disk is ephemeral. Send them to Amazon S3 or Cloudflare R2 and keep only the object key and owner_id in your database. The GET handler still looks up the row, checks the user, and streams the object.

If you need to email a file to someone who is not signed in, give them a short-lived signed URL. Do not put the file in static/.