Serving uploaded files behind auth in SvelteKit
Aug 15, 2026 SvelteKit
The upload tutorial created a +server.ts handler that returns any file in uploads/ to whoever asks for its filename. That works for avatars or a public gallery, but private files like invoices or ID scans need authentication on the GET request itself. Because an <img> tag triggers a separate HTTP request, SvelteKit’s layout load never touches it. And because layouts do not wrap +server.ts routes, the auth check has to live directly inside the handler. SvelteKit routing covers that split between pages and HTTP handlers.
This guide builds on the file upload handler and the session setup from HttpOnly cookie auth. Because it checks sessions dynamically at runtime, you need a Node or serverless adapter; adapter-static cannot run server route handlers.
You will create or modify 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 Table of Contents
Why a UUID is not enough
A random UUID in the URL feels private until someone shares a link. Chat apps fetch previews when pasted, proxies log full request paths, and browsers can leak URLs in the Referer header to third-party assets. Treat filenames as identifiers, not access keys.
Step 1: refuse anonymous GET
Your hooks.server.ts already populates locals.user from the session cookie. When a browser renders <img src="/upload/..."> from the same origin, it sends that cookie automatically without needing client-side JavaScript to pass tokens.
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 endpoint. If you do, the browser follows the redirect and attempts to render the login page HTML as image pixels. Return error(401) instead, and handle redirects on page routes where navigation is expected. SvelteKit redirects is that page-route side.
The cache-control: private, no-store header stops browsers and intermediate proxies from retaining a copy. private by itself is insufficient because a shared computer could still serve the image from disk cache after a user logs out. If you need caching later for larger files, combine short max-age windows with validation headers like ETag, re-checking the session on every request. For private uploads, no-store is the safest default.
Avoid putting a public CDN in front of private asset routes. Many edge caches cache successful 200 responses by default, which can cause user B to receive user A’s cached file without hitting your server.
Step 2: check the owner, not just the session
Blocking anonymous requests is only half the job: any authenticated user could still guess or scrape filenames belonging to other accounts. To prevent that, associate each uploaded file with locals.user.id and verify ownership on every read.
Create src/lib/server/uploads.ts. Code under $lib/server is restricted to the server environment, so accidentally importing it in a frontend component will throw a build error instead of leaking the manifest.
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);
} This flat JSON file keeps the example simple and self-contained. In a production app where concurrent writes could collide, replace the JSON helpers with a database table (name, owner_id, created_at). Drizzle with Postgres or Drizzle with SQLite are two ways to do that. The GET handler logic stays identical.
If a file has no corresponding record in the database, treat it as missing so leftover files from before you added ownership remain 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 a 404 status when a file does not exist as well as when it belongs to another user. Returning a 403 Forbidden confirms that the file ID is valid. The page load function already filters the UI list, so a 404 here represents a stale bookmark or someone probing IDs.
The +page.svelte component requires no changes. It renders data.files, which the server load function now filters to the active user.
The full GET handler with both checks:
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');
}
}; The ALLOWED extension map also blocks direct access to manifest.json. If an endpoint ever served arbitrary files from the directory without an extension whitelist, clients could download the full list of uploads and user IDs.
Try it
- Sign in, upload an image, and copy the
/upload/<id>.jpgURL. - Open the URL in a private window. You should receive a 401 error with a broken image instead of the file.
- Sign in as a second user and open the same URL. You should get a 404.
- Sign back in as the original user. The image loads again.
If step 2 still displays the image, the browser may be serving a cached copy from before the headers were updated, or the file is being served from static/ instead of the dynamic handler.
Why an img tag works here
Browsers attach HttpOnly cookies to same-origin subresource requests automatically, including <img> elements. JavaScript cannot read the cookie value directly, but the browser still transmits it with the request.
Plain <img src="..."> tags cannot set custom Authorization headers. If your SPA stores Bearer tokens in JavaScript memory or localStorage, standard image tags will trigger unauthenticated requests. The typical workaround is fetching the image as a blob with explicit headers and creating a local URL with URL.createObjectURL, though that prevents users from opening the image in a separate browser tab as a normal URL.
Setting SameSite=Lax on the session cookie also stops other origins from embedding your private images. A cross-site <img> tag counts as a cross-site subresource request, so the browser withholds the cookie and the server returns 401.
Things that trip people up
- Unprotected GET routes: Adding auth checks to the upload form while leaving the file GET handler accessible to anyone with the filename.
- Relying on layout auth: Placing
+server.tsinside a route group with a+layout.server.tsauth guard. Layout loads only run for page routes, never for server endpoints. - Redirecting on asset requests: Calling
redirect(303, '/login')inside a file handler, which fills the image slot with login page HTML. - Exposing IDs via directory listings: Reading the filesystem with
readdirwithout filtering by owner, which exposes other users’ file IDs in the page markup. - Saving files in
static/: Placing user uploads in thestatic/folder, which serves them publicly without executing SvelteKit hooks or route checks. - Using 403 instead of 404: Returning Forbidden instead of Not Found confirms to an attacker that the file ID exists.
- Public caching headers: Using
Cache-Control: publicon private uploads, which lets CDNs and proxy caches deliver one user’s file to other visitors.
Next steps
Local disk storage works well for development, but serverless hosts like Vercel discard filesystem writes between invocations. For production, upload to S3 with a presigned URL and store only the object key and owner_id in your database. The GET handler keeps the same session and ownership checks, then streams the object back or redirects to a short-lived signed GET.
Check the file header before you record the upload. file.type is still only what the browser claimed.
If you need to share a private file with someone who is not logged in (such as an emailed link), generate a time-limited signed URL from your object storage provider instead of making the file public.