Validating File Type with Magic Bytes
How do you tell, before uploading, that invoice.pdf is really a PDF and not a renamed executable, a HEIC photo, or a Word document someone saved with the wrong extension? The browser’s file.type cannot tell you — it is guessed from the extension. The file’s own first bytes can: most formats begin with a fixed signature, informally called “magic bytes”. This recipe reads the first few bytes with file.slice(), matches them against a signature table, cross-checks the result with the extension, and reports mismatches through setCustomValidity() on the file input, so the verdict flows through the same Constraint Validation API path as every other field.
When to Use Signature Sniffing
Use signature checks whenever the file type matters to what happens next: a document pipeline that only handles PDFs, an avatar uploader that only resizes PNG and JPEG, an import that expects a real ZIP archive. Signature sniffing is especially valuable when:
- Users export from other apps, which often produce the wrong extension (a “PDF” that is really a Word document, a “.jpg” that is really WebP).
- The upload is large, so rejecting it after a thirty-second upload is expensive for the user.
- You want a precise message — “photo.jpg is actually a HEIC image” is far more useful than “Invalid file”.
It is not a security control on its own. A crafted file can start with a valid PDF signature and contain anything after it; the server must parse, sanitise or scan the file regardless. Treat the browser check as a fast, friendly first pass, as the file upload validation topic describes.
Minimal Working Signature Check
interface Signature { mime: string; label: string; offset?: number; bytes: (number | null)[] } // null = wildcard
// Ordered: longer / more specific signatures first.
export const SIGNATURES: Signature[] = [
{ mime: "image/png", label: "PNG image", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ mime: "application/pdf", label: "PDF document", bytes: [0x25, 0x50, 0x44, 0x46, 0x2d] }, // %PDF-
{ mime: "image/jpeg", label: "JPEG image", bytes: [0xff, 0xd8, 0xff] },
{ mime: "image/gif", label: "GIF image", bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8
{ mime: "image/webp", label: "WebP image", bytes: [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50] },
{ mime: "image/heic", label: "HEIC photo", offset: 4, bytes: [0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63] }, // ftypheic
{ mime: "application/zip", label: "ZIP archive (or DOCX/XLSX)", bytes: [0x50, 0x4b, 0x03, 0x04] },
{ mime: "application/x-msdownload", label: "Windows program", bytes: [0x4d, 0x5a] }, // MZ
];
const HEAD_BYTES = 16;
export async function detectType(file: File): Promise<Signature | undefined> {
const head = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer());
return SIGNATURES.find((sig) =>
sig.bytes.every((b, i) => b === null || head[(sig.offset ?? 0) + i] === b),
);
}
const ALLOWED = new Map([
["application/pdf", [".pdf"]],
["image/png", [".png"]],
["image/jpeg", [".jpg", ".jpeg"]],
]);
export async function typeError(file: File): Promise<string> {
const ext = file.name.includes(".") ? file.name.slice(file.name.lastIndexOf(".")).toLowerCase() : "";
const sig = await detectType(file);
if (!sig) return `We couldn't recognise ${file.name}. Upload a PDF, PNG or JPEG file.`;
if (!ALLOWED.has(sig.mime)) return `${file.name} is a ${sig.label}. Upload a PDF, PNG or JPEG file.`;
// An allowed type with a misleading extension (a PNG named .pdf) is accepted on purpose:
// the content is what matters, and the server stores it under the detected type.
if (!ALLOWED.get(sig.mime)!.includes(ext)) console.info(`${file.name}: extension ${ext} but content is ${sig.label}`);
return "";
}
// Wiring to the input
const input = document.querySelector<HTMLInputElement>("#attachment")!;
input.addEventListener("change", async () => {
const errors = await Promise.all([...(input.files ?? [])].map(typeError));
input.setCustomValidity(errors.find(Boolean) ?? "");
input.toggleAttribute("aria-invalid", errors.some(Boolean));
});
Reading sixteen bytes is effectively instant for any file size, because slice() creates a view into the file on disk and arrayBuffer() only materialises the requested range. There is no need to show a spinner for this step.
Signature Table Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
bytes |
(number | null)[] |
— | Expected leading bytes; null skips a position |
offset |
number |
0 |
For formats whose marker is not at byte 0 (ISO media: ftyp at 4) |
label |
string |
— | Human name used in error messages |
HEAD_BYTES |
number |
16 |
Bytes read per file; enough for all entries above |
ALLOWED |
Map<mime, ext[]> |
PDF, PNG, JPEG | Allow-list of detected types and their expected extensions |
Order of SIGNATURES |
array order | specific first | Prevents a short signature shadowing a longer one |
The table is deliberately an allow-list in spirit: detection covers a few common disallowed types (HEIC, ZIP, Windows programs) only so the error message can say what the file actually is. Everything unrecognised is rejected with a generic message.
Verification Steps
import { describe, it, expect } from "vitest";
import { detectType, typeError } from "./sniff";
const file = (bytes: number[], name: string) => new File([new Uint8Array(bytes)], name);
describe("detectType", () => {
it("recognises a real PDF regardless of name", async () => {
expect((await detectType(file([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31], "x.bin")))?.mime).toBe("application/pdf");
});
it("sees through a renamed PNG", async () => {
const png = file([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "invoice.pdf");
expect(await typeError(png)).toBe(""); // PNG is allowed; name is just misleading
});
it("rejects a Windows program named .pdf", async () => {
expect(await typeError(file([0x4d, 0x5a, 0x90, 0x00], "invoice.pdf"))).toMatch(/Windows program/);
});
});
Edge Cases and Failure Modes
Formats without a signature. Plain text, CSV and SVG have no magic bytes. For CSV, read the first few kilobytes and check they decode as UTF-8 and contain the expected delimiter; for SVG, never trust it as an image — it is XML that can contain script, so either reject it or sanitise it on the server.
Container formats. DOCX, XLSX, EPUB and JAR are all ZIP files, so their signature is PK\x03\x04. Distinguishing them requires reading the ZIP’s central directory for word/document.xml or [Content_Types].xml. If you accept Office files, do that on the server; in the browser, “a ZIP-based document” plus the extension is a reasonable signal.
Byte-order marks and leading whitespace. Some tools prepend a UTF-8 BOM (EF BB BF) or whitespace to text-based formats, and a few PDF generators write junk before %PDF. The PDF specification tolerates %PDF within the first 1024 bytes; if you see false rejections from a known generator, widen the search window for that one signature rather than loosening the whole table.
Reading fails. A file on a disconnected network drive or revoked by the OS can reject arrayBuffer() with NotReadableError. Catch it and show “We couldn’t read this file — try choosing it again” instead of an unhandled rejection.
Accepting a Misnamed File Versus Rejecting It
When the content is an allowed type but the extension is wrong — a PNG called scan.pdf — there are two defensible responses. Rejecting is simplest to explain but punishes users for another application’s mistake. Accepting, and letting the server store the file under a generated name with the correct extension, is kinder and removes the mismatch for every later consumer. The recipe above accepts, because the content is what matters to your pipeline, and the name the user sees in their own file system is not your concern. If you do accept, make sure the server trusts the detected type, not the uploaded name, when it later sets Content-Type for downloads; serving a PNG as application/pdf produces a broken download that looks like your bug.
Why the Server Still Has to Check
Everything above runs in code the user controls. A request crafted outside the browser can send any bytes under any name, so the server repeats the signature check on the bytes it receives, ideally with a maintained library that knows hundreds of formats and handles the container cases. The server is also the only place for the deeper checks that matter for safety: parsing the file with a hardened library, stripping metadata, and scanning for malware before anyone else can download it. The principle — client checks for speed, server checks for truth — is spelled out in why client-side validation is not security.
Frequently Asked Questions
Is checking magic bytes in the browser enough to block malicious files?
No. It catches renamed and mislabelled files quickly, which helps honest users, but anyone can craft a file with a valid signature followed by arbitrary content. The server must parse, sanitise or scan files regardless.
How many bytes do I need to read to detect a file type?
For common formats, the first 12 to 16 bytes are enough. ISO media formats such as HEIC and MP4 put their marker at offset 4, so read at least 12 bytes. Use file.slice() so large files are never loaded fully.
Why is file.type wrong for my file?
The browser derives file.type from the file extension using the operating system's type registry. A renamed file gets the wrong type, and an unknown extension gets an empty string.
How do I detect SVG or CSV files, which have no signature?
Read a small chunk as text and check its structure: CSV should decode as UTF-8 with the expected delimiter, and SVG starts with an XML declaration or an svg element. Treat SVG as a document that can contain script, not as a safe image.
Related Guides
- File Upload Validation — the full pipeline this check belongs to.
- Enforcing File Size Limits Before Upload — the check that runs just before sniffing.
- Validating Drag and Drop File Uploads — routing dropped files through the same sniffing.
- Honeypot Fields and Bot Detection — other defences for public upload forms.