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.

Three ways to guess a file's type Three columns comparing the file extension, the browser's file.type property and magic-byte signature sniffing as ways to determine file type. Extension • read from file.name ✓ instant, always available ✗ renaming changes it ✗ many apps export the wrong one file.type • guessed by the OS from the extension ✓ instant ✗ empty for unknown extensions ✗ renaming changes it Magic bytes • first 4–16 bytes of the content ✓ reflects what the file really is ✓ reading is fast even for huge files ✗ some formats have no signature
Only the signature looks at the file's content; the other two are derived from its name.

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.

Content-based type check The file is sliced to its first sixteen bytes, the bytes are compared against a table of signatures, the detected type is checked against the allowed list, and the extension is compared as a secondary signal. Slice file.slice(0, 16) Read arrayBuffer() → Uint8Array Match signature table, wildcards Allow-list PDF, PNG, JPEG only Extension secondary, for the message
The extension is only consulted after the content has been identified, so a misleading name can never override what the bytes say.

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.

Common file signatures A table listing file types with their leading signature bytes and whether the site accepts them. First bytes Accepted PDF 25 50 44 46 2D (%PDF-) ✓ Yes PNG 89 50 4E 47 0D 0A 1A 0A ✓ Yes JPEG FF D8 FF ✓ Yes WebP RIFF .... WEBP ✗ No HEIC .... ftypheic ✗ No ZIP / DOCX 50 4B 03 04 (PK) ✗ No
Most formats identify themselves in the first four to twelve bytes; ISO media formats such as HEIC put their marker at offset four.

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.

← Back to File Upload Validation