Validating image uploads with magic bytes in SvelteKit
Aug 17, 2026 SvelteKit
The file upload tutorial already rejects a file whose name or file.type is not on an allow list. That is a first filter, not a type check.
file.type is the MIME type the browser put on the File. The user (or a script) chose the filename. Rename notes.txt to photo.png, or set the type in a handmade FormData, and both checks pass. The bytes on disk are still a text file.
A magic number is a short sequence at a known offset, usually the start of the file, that the format itself defines. You read those bytes on the server and compare them to the signatures you allow, without looking at the filename.
This post adds that check to the form action from the upload tutorial. You need a Node or serverless adapter. adapter-static cannot run the action.
You will create or change these files:
src/lib/image.ts
src/routes/upload/+page.server.ts The form in +page.svelte does not change. accept on the file input still only affects the picker.
Table of Contents
What the first bytes look like
A JPEG starts with the Start Of Image marker FF D8, then another FF for the next marker. JFIF files continue E0, Exif files E1, some others DB. Checking the first three bytes, FF D8 FF, covers those.
A PNG always starts with eight bytes:
89 50 4E 47 0D 0A 1A 0A 50 4E 47 is the ASCII string PNG. The 0D 0A 1A 0A sequence is there so a file that went through a text-mode transfer shows up as damaged instead of as a valid image.
A WebP file is a RIFF container. The first four bytes are RIFF and bytes 8-11 are WEBP. Bytes 4-7 are a size field, so they change from file to file. You have to check both ends, because RIFF alone also matches a WAV or an AVI.
Twelve bytes is enough for all three.
Step 1: sniff the header
Create src/lib/image.ts. This module has no secrets and no Node APIs, so it does not belong under $lib/server. The form action will call it. You can also call it in the browser later if you want a faster error before submit.
export type AllowedImage = {
ext: '.jpg' | '.png' | '.webp';
mime: 'image/jpeg' | 'image/png' | 'image/webp';
};
function hasPrefix(bytes: Uint8Array, prefix: readonly number[]): boolean {
if (bytes.length < prefix.length) return false;
return prefix.every((value, i) => bytes[i] === value);
}
export function sniffImage(bytes: Uint8Array): AllowedImage | null {
if (hasPrefix(bytes, [0xff, 0xd8, 0xff])) {
return { ext: '.jpg', mime: 'image/jpeg' };
}
if (hasPrefix(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return { ext: '.png', mime: 'image/png' };
}
if (
bytes.length >= 12 &&
hasPrefix(bytes, [0x52, 0x49, 0x46, 0x46]) &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return { ext: '.webp', mime: 'image/webp' };
}
return null;
} 0x52 0x49 0x46 0x46 is RIFF. 0x57 0x45 0x42 0x50 is WEBP. The function returns the extension and MIME type you should use, or null.
You do not need the whole file. File is a Blob, so file.slice(0, 12) gives you a blob of the header and arrayBuffer() reads only those bytes.
If you later allow many more formats, file-type is the same idea with a long signature table. For three image types the table above is small enough to own.
Step 2: reject the file in the action
Replace the extension and file.type check in src/routes/upload/+page.server.ts with a sniff. Keep the size check. Derive the saved name from the sniff result, not from file.name.
import { fail } from '@sveltejs/kit';
import { mkdir, readdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { sniffImage } from '$lib/image';
import type { Actions, PageServerLoad } from './$types';
const UPLOAD_DIR = 'uploads';
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED_EXT = new Set(['.jpg', '.png', '.webp']);
export const load: PageServerLoad = async () => {
await mkdir(UPLOAD_DIR, { recursive: true });
const names = (await readdir(UPLOAD_DIR)).filter((name) => ALLOWED_EXT.has(extname(name)));
return { files: names };
};
export const actions: Actions = {
default: async ({ request }) => {
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 header = new Uint8Array(await file.slice(0, 12).arrayBuffer());
const kind = sniffImage(header);
if (!kind) {
return fail(400, { message: 'Only JPEG, PNG or WebP images' });
}
const name = `${crypto.randomUUID()}${kind.ext}`;
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer()));
return { success: true };
}
}; A JPEG uploaded as photo.png is stored as <uuid>.jpg. The GET handler from the first tutorial already maps .jpg to image/jpeg, so the <img> tag gets the type that matches the bytes.
Leave file.type out of the decision. If you require it to match the sniff, a browser that sends an empty type (some older WebViews do) rejects a real image. The header is enough.
The GET handler should still send x-content-type-options: nosniff and still set content-type from the extension you assigned. That stops the browser from guessing a different type if someone later appends HTML to a file that starts with a JPEG header.
Anyone who can load /upload can still POST to it. If this is a user avatar, check locals.user the same way HttpOnly cookie auth does, and record the owner as in serving uploaded files behind auth.
Try it
- Open
/uploadand submit a small JPEG or PNG. It should appear under the form, with a.jpgor.pngname. - Create a text file, rename it to
fake.png, and submit that. The action should returnOnly JPEG, PNG or WebP images. - Copy a real JPEG to
also-fake.pngand submit it. It should save as.jpg, not.png.
If step 2 is accepted, the action is still looking at file.name or file.type.
After a presigned S3 upload
A presigned PUT never sends the file through SvelteKit. The presign handler only sees JSON: a claimed name, size, and contentType. S3 stores Content-Type as object metadata and does not check that the body matches that type. A client can PUT an HTML document to a key you signed as image/jpeg.
Sniff the object after it lands, before you record it as an upload.
Have the page POST the key to a new route once S3 returns 200. Create src/routes/upload/complete/+server.ts:
import { error, json } from '@sveltejs/kit';
import { DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { sniffImage } from '$lib/image';
import { s3, S3_BUCKET } from '$lib/server/s3';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
error(401, 'Sign in');
}
const body = await request.json();
const key = typeof body.key === 'string' ? body.key : '';
const prefix = `uploads/${locals.user.id}/`;
if (!key.startsWith(prefix) || key.includes('..')) {
error(400, 'Bad key');
}
let obj;
try {
obj = await s3.send(
new GetObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Range: 'bytes=0-11'
})
);
} catch {
error(400, 'Upload not found');
}
const header = obj.Body ? await obj.Body.transformToByteArray() : new Uint8Array();
const kind = sniffImage(header);
if (!kind) {
await s3.send(new DeleteObjectCommand({ Bucket: S3_BUCKET, Key: key }));
error(400, 'Only JPEG, PNG or WebP images');
}
return json({ ok: true, mime: kind.mime });
}; Range: bytes=0-11 asks S3 for the header only. You do not pull a 100 MB object into the function to read twelve bytes.
Delete the object if the sniff fails. Otherwise a rejected upload still sits in the bucket, and a later code path that trusts the key would serve it.
Check that key starts with uploads/${locals.user.id}/. The presign handler in the S3 post is what creates that prefix. Without this check, anyone signed in could point the complete route at someone else’s key, or at an object you never meant to inspect.
Keep key from the presign JSON. After the PUT succeeds, post it to this route before you show the preview:
const { url, key, contentType, previewUrl } = await sign.json();
const put = await fetch(url, {
method: 'PUT',
headers: { 'content-type': contentType },
body: file
});
if (!put.ok) {
message = 'S3 rejected the upload';
return;
}
const done = await fetch('/upload/complete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key })
});
if (!done.ok) {
message = 'File is not a JPEG, PNG or WebP image';
return;
}
preview = previewUrl; Do not write the row in your database from the presign handler. Presign means “this user may start a PUT”, not “the object is a valid image”.
What this does not prove
A polyglot can begin with FF D8 FF and still contain HTML or a script later in the file. The sniff only tells you the file starts like an image. Serving it as image/jpeg with nosniff is what keeps the browser from executing that tail. Re-encoding the upload with a library such as sharp is stronger: you write a new file you produced, and you drop whatever was hiding after the image data.
SVG is XML. There is no useful magic number, and image/svg+xml can run script in the browser. Do not treat SVG as an image upload. If you need user-supplied icons, sanitize them on the server or convert them to PNG.
A GIF starts with GIF87a or GIF89a. Add those six bytes to sniffImage if you want GIF. Formats that live in an ISO base media box (AVIF, HEIC) need a longer parse than a prefix check.
Do not run this check only in +page.svelte. A client can skip your JavaScript and POST the form, or call /upload/presign directly.
Things that trip people up
Checking RIFF and stopping. A WAV file would pass. WebP needs WEBP at offset 8.
Saving with the original extension after a successful sniff. A JPEG named photo.png would then be served as image/png.
Requiring file.type to match. Some browsers send an empty type. The header is enough.
Sniffing in the browser and skipping the server. The server is the only place a hostile client cannot edit.
Accepting the S3 object because the presign request said image/jpeg. S3 did not look at the body.
Leaving a failed S3 object in the bucket. A later path that trusts the key would still serve it.
Serving the file without nosniff, or putting uploads in static/, where you do not control the headers.
What to add next
Check locals.user on the action and the GET, as in serving uploaded files behind auth.
If the file is larger than your function’s body limit, upload to S3 with a presigned URL and run the complete-route sniff above before you insert a row.
Re-encode the image if you need to strip GPS Exif data or to produce a thumbnail. The sniff tells you it is safe to hand the buffer to a decoder. The decoder output is what you store.
The form still has no progress bar, because use:enhance and fetch do not expose upload progress. If you need a percent, listen to XMLHttpRequest.upload.onprogress on the request that carries the bytes.