File Type Detection in the Browser

An <input type="file"> hands you a File whose type property was produced by looking up the filename’s extension in a table on the user’s machine — no byte of the file was read to produce it. Every upload pipeline that branches on file.type is therefore branching on a string the user controls with a rename, and on some machines that string is empty for perfectly ordinary media.

This guide covers the layer that sits between the file picker and the first byte on the wire: reading a bounded header, matching it against container signatures, reconciling the result with what the operating system claimed, and deciding what to do when the two disagree. It is part of upload fundamentals and browser APIs, and it assumes you already have a File in hand from the File API and Blob objects.

Prerequisites

  • [ ] A File reference from an <input type="file">, a drag-and-drop drop zone, or a clipboard paste handler.
  • [ ] Blob.prototype.arrayBuffer() — Chrome 76+, Firefox 69+, Safari 14+. On anything older you need the FileReader and ArrayBuffer path, which the code below falls back to automatically.
  • [ ] TypeScript 5.x with lib: ["DOM", "DOM.Iterable", "ES2022"], or plain ESM in any current browser. No dependencies.
  • [ ] A server endpoint that repeats the check, because everything on this page runs on the user’s machine. Pair it with server-side file validation before you ship.
  • [ ] Test fixtures on disk: a real JPEG, a WebP, an MP4 from a phone, a .docx, and one file you have deliberately renamed to the wrong extension.

How it works

Three separate mechanisms are usually confused with each other, and only one of them looks at the file’s contents.

The accept attribute is a hint to the operating system’s file picker. It changes which files are greyed out in the open dialog and nothing else. The Windows dialog ends its type dropdown with “All Files (*.*)”; macOS lets a user drag any file into the panel regardless. When the filter is bypassed the resulting change event is byte-for-byte identical to a compliant selection — no flag, no validity state, no console warning. The details of the token grammar and the per-platform quirks are in restricting uploads with the accept attribute.

File.type is an extension lookup. The File API specification says the user agent “should” set it to the file’s MIME type and supplies no algorithm; every engine splits the filename on the last dot and consults a table — Chromium’s built-in mapping first, then the Windows registry, macOS Uniform Type Identifiers, or the freedesktop MIME database. Those tables are mutable and per-machine, which is why the same notes.csv reports text/csv on macOS and application/vnd.ms-excel on a Windows box with Office installed. Why browser MIME types are unreliable has the measured field values.

Magic-byte detection is the only one of the three that reads the file. Container formats put a fixed identifying sequence at or near byte zero: FF D8 FF for JPEG, 89 50 4E 47 0D 0A 1A 0A for PNG, %PDF- for PDF. Reading 32 bytes and comparing them against a table costs one disk page and takes under a millisecond regardless of whether the file is 4 KB or 4 GB, because Blob.slice() produces a lazy view and only the slice is materialised.

Four layers of file type checking and what each one proves A four-row table comparing the accept attribute, the File.type property, magic-byte detection and server-side validation, showing what each mechanism does and what it actually guarantees. Four checks, one guarantee LAYER WHAT IT DOES WHAT IT PROVES accept attribute HTML markup greys out files in the OS picker never inspects anything nothing one click turns it off File.type File API property extension to OS MIME table lookup result varies per machine the filename a rename rewrites it magic bytes Blob.slice + read matches the first 32 bytes about 0.3 ms, any file size how it starts a header can be forged server validation libmagic or decode parses or re-encodes the payload the client cannot skip it the whole file this is the control The top three rows buy user experience and bandwidth. Only the bottom row enforces anything.
Client-side detection exists to fail fast and fail politely; the enforceable answer is always produced by code the user cannot edit.

The practical consequence is that client detection has exactly two jobs. The first is telling a user within 50 ms that the file they picked is not the file your product wants, instead of after an eight-minute mobile upload ends in a 415 Unsupported Media Type. The second is producing a recorded disagreement: when the declared type and the detected type differ, that is worth logging with the account id, because a population of users whose files consistently lie is a population worth rate limiting.

Signature coverage for common media containers

A table of eight signatures covers the overwhelming majority of what a media product actually receives. The important column is the offset: two of the most-uploaded formats do not put their identifier at byte zero.

Format Offset Bytes (hex) ASCII Notes
JPEG 0 FF D8 FF Fourth byte varies: E0 JFIF, E1 Exif, DB raw
PNG 0 89 50 4E 47 0D 0A 1A 0A .PNG.... The 0D 0A pair detects FTP text-mode corruption
GIF 0 47 49 46 38 37 61 / …39 61 GIF87a / GIF89a Only two legal variants
WebP 0 and 8 52 49 46 46 + 57 45 42 50 RIFF + WEBP Bytes 4–7 are a per-file length
MP4 / MOV / HEIC / AVIF 4 66 74 79 70 ftyp Bytes 8–11 are the brand that disambiguates
PDF 0 25 50 44 46 2D %PDF- Version digits follow, e.g. 1.7
ZIP, OOXML, APK, EPUB 0 50 4B 03 04 PK.. Identifies the wrapper, never the document
Windows PE 0 4D 5A MZ The one you are actually trying to catch

Three of these rows carry a warning. WebP is a RIFF container whose four size bytes sit between the magic and the form type, so a matcher anchored at byte zero sees only RIFF — which is equally .wav and .avi. The ISO base media family is worse: ftyp at byte 4 covers MP4 video, QuickTime, HEIC photos from every iPhone since the 7, and AVIF, and those need completely different handling downstream. And PK 03 04 is shared byte-for-byte by .docx, .xlsx, .pptx, .odt, .apk, .jar and .epub; no amount of header reading separates them. Detecting file type from magic bytes in JavaScript works through the ISO brand table and the ZIP entry-name trick in detail.

What has no signature at all is text. SVG, CSV, JSON, Markdown and plain text begin with arbitrary bytes, optionally behind a UTF-8 byte-order mark (EF BB BF). If your policy is “reject anything the detector does not recognise”, you have just broken every CSV import in your product — and SVG, which is executable content, will sail through any check that only asks “does this parse as XML”.

Step-by-step implementation

The five steps below build one module. Each block is complete and runs as written.

1. Narrow the picker, but treat it as cosmetic

Set accept with both extension and MIME tokens, because the two match different things on different platforms, and always add the extensions for formats whose MIME entry is commonly missing.

<input
  id="file"
  type="file"
  multiple
  accept="image/jpeg,image/png,image/webp,image/avif,.heic,.heif,image/heic,video/mp4,.mov"
/>

.heic and .heif are listed as bare extensions alongside their media types on purpose: a Windows 10 machine without the HEIF Image Extensions package has no registry entry for .heic, so the MIME token alone hides the file and the user’s own camera roll appears empty. The same reasoning applies to .mov on Linux desktops.

Nothing about this list is a check. It is a convenience that reduces how often a user picks the wrong thing, and the code in step 4 must behave identically whether or not the filter was honoured.

2. Read a bounded header

Slice first, read second. This is the single rule that separates a detector that works on a 4 GB video from one that kills the tab.

// header.ts — bounded, fallback-safe header reads.

/** 32 bytes covers every signature in the table, with headroom for Matroska. */
export const DEFAULT_HEADER_BYTES = 32;

function readViaFileReader(blob: Blob): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as ArrayBuffer);
    reader.onerror = () => reject(reader.error ?? new Error("FileReader failed"));
    reader.readAsArrayBuffer(blob);
  });
}

/**
 * Materialises at most `byteCount` bytes from the front of `blob`.
 * Cost is one disk page whether the source is 4 KB or 4 GB.
 */
export async function readHeader(
  blob: Blob,
  byteCount: number = DEFAULT_HEADER_BYTES,
): Promise<Uint8Array> {
  const head = blob.slice(0, byteCount);
  if (typeof head.arrayBuffer === "function") {
    return new Uint8Array(await head.arrayBuffer());
  }
  return new Uint8Array(await readViaFileReader(head));
}

blob.slice(0, 32) performs no I/O. It returns a new Blob that references the same underlying file with an offset and a length, exactly as described in slicing large files with Blob.slice; the read happens when arrayBuffer() resolves. Skip the slice and call await file.arrayBuffer() on the whole file, and Chrome throws RangeError: Array buffer allocation failed somewhere north of 2 GB while iOS Safari kills the tab outright with nothing catchable.

Bounded header read compared with reading the whole file The top lane shows five sub-millisecond stages from the change event to a verdict; the bottom lane shows a single whole-file read taking seconds and allocating gigabytes. Same 2 GB file, two reading strategies Bounded — slice(0, 32) then arrayBuffer() change slice(0,32) read bytes match table verdict 0 ms 0 ms, no I/O 0.31 ms 0.02 ms 0.33 ms total Unbounded — await file.arrayBuffer() copy all 2 147 483 648 bytes into the JS heap 3.4 s of blocked reads · 2 GB allocated · RangeError in Chrome, tab killed on iOS Safari Detection cost is constant. It does not scale with the file you are detecting. Figures from Chrome 126 on an NVMe laptop; the ratio holds everywhere.
Because the read is bounded, you can run detection on every file in a 500-item folder drop without the user noticing.

3. Match against a masked signature table

Writing signatures as hex strings with ?? wildcards keeps the offset problem in the data rather than in the matcher. A WebP pattern becomes one string that skips the four length bytes.

// signatures.ts — wildcard-masked magic-byte patterns.

export interface Pattern {
  readonly mime: string;
  readonly ext: string;
  /** null entries are wildcards: any byte matches. */
  readonly mask: readonly (number | null)[];
  /** True when the bytes identify a wrapper, not a final format. */
  readonly container: boolean;
  readonly specificity: number;
}

function pattern(mime: string, ext: string, hex: string, container = false): Pattern {
  const mask = hex.trim().split(/\s+/).map((token) =>
    token === "??" ? null : Number.parseInt(token, 16),
  );
  if (mask.some((byte) => byte !== null && Number.isNaN(byte))) {
    throw new SyntaxError(`bad signature for ${mime}: "${hex}"`);
  }
  return {
    mime,
    ext,
    mask,
    container,
    specificity: mask.filter((byte) => byte !== null).length,
  };
}

export const PATTERNS: readonly Pattern[] = [
  pattern("image/png", "png", "89 50 4E 47 0D 0A 1A 0A"),
  pattern("image/jpeg", "jpg", "FF D8 FF"),
  pattern("image/gif", "gif", "47 49 46 38 37 61"),
  pattern("image/gif", "gif", "47 49 46 38 39 61"),
  pattern("image/webp", "webp", "52 49 46 46 ?? ?? ?? ?? 57 45 42 50"),
  pattern("audio/wav", "wav", "52 49 46 46 ?? ?? ?? ?? 57 41 56 45"),
  pattern("video/x-msvideo", "avi", "52 49 46 46 ?? ?? ?? ?? 41 56 49 20"),
  pattern("video/mp4", "mp4", "?? ?? ?? ?? 66 74 79 70", true),
  pattern("application/pdf", "pdf", "25 50 44 46 2D"),
  pattern("application/zip", "zip", "50 4B 03 04", true),
  pattern("application/vnd.microsoft.portable-executable", "exe", "4D 5A"),
]
  // Most specific first, so RIFF/WEBP beats a hypothetical bare-RIFF rule.
  .sort((a, b) => b.specificity - a.specificity);

/** Brand at bytes 8-11 splits the ISO base media family apart. */
const ISO_BRANDS: Readonly<Record<string, string>> = {
  avif: "image/avif",
  heic: "image/heic",
  heix: "image/heic",
  mif1: "image/heif",
  qt: "video/quicktime",
  M4A: "audio/mp4",
  "3gp4": "video/3gpp",
};

export interface Match {
  readonly mime: string;
  readonly ext: string;
  readonly container: boolean;
}

export function matchHeader(header: Uint8Array): Match | null {
  for (const p of PATTERNS) {
    if (header.length < p.mask.length) continue;
    let ok = true;
    for (let i = 0; i < p.mask.length; i++) {
      const expected = p.mask[i];
      if (expected !== null && header[i] !== expected) {
        ok = false;
        break;
      }
    }
    if (!ok) continue;
    if (p.mime === "video/mp4" && header.length >= 12) {
      const brand = new TextDecoder("latin1").decode(header.subarray(8, 12)).trim();
      const refined = ISO_BRANDS[brand];
      if (refined) return { mime: refined, ext: brand.toLowerCase(), container: false };
    }
    return { mime: p.mime, ext: p.ext, container: p.container };
  }
  return null;
}

Two design decisions are worth calling out. Sorting by specificity — the count of non-wildcard bytes — is what stops a bare RIFF rule from claiming every WebP; with the three RIFF entries above they all have twelve mask slots but eight concrete bytes each, so they are mutually exclusive and order between them does not matter. And the ?? ?? ?? ?? prefix on the MP4 pattern is deliberately lossy: it skips the box length without checking it. Real QuickTime exports occasionally place a free, skip or wide box before ftyp, which pushes the signature past byte 4 and defeats a fixed mask entirely — walking the box chain properly is covered in the magic-bytes article.

4. Turn two opinions into one verdict

Detection is not a boolean. You have a declared type from the OS, a detected type from the bytes, and an allow list, and the interesting cases are where they disagree.

// inspect.ts
import { readHeader, DEFAULT_HEADER_BYTES } from "./header.js";
import { matchHeader } from "./signatures.js";

export type Verdict = "confirmed" | "conflict" | "undeclared" | "unrecognised";

export interface Inspection {
  readonly name: string;
  readonly size: number;
  readonly declared: string;
  readonly detected: string | null;
  readonly container: boolean;
  readonly verdict: Verdict;
  readonly accepted: boolean;
  readonly reason: string;
}

export interface InspectOptions {
  readonly allow: readonly string[];
  readonly headerBytes?: number;
  /** Extensions with no binary signature that you still want to accept. */
  readonly textExtensions?: readonly string[];
  /** What to do when nothing matches and the extension is not text. */
  readonly onUnrecognised?: "reject" | "defer";
}

/** Platform spellings that mean the same real format. */
const ALIASES: Readonly<Record<string, string>> = {
  "image/jpg": "image/jpeg",
  "image/pjpeg": "image/jpeg",
  "image/x-png": "image/png",
  "application/vnd.ms-excel": "text/csv", // what Windows reports for .csv with Office installed
};

const normalise = (mime: string): string => {
  const bare = mime.split(";")[0].trim().toLowerCase();
  return ALIASES[bare] ?? bare;
};

const extensionOf = (name: string): string => {
  const dot = name.lastIndexOf(".");
  return dot === -1 ? "" : name.slice(dot).toLowerCase();
};

export async function inspect(file: File, options: InspectOptions): Promise<Inspection> {
  const {
    allow,
    headerBytes = DEFAULT_HEADER_BYTES,
    textExtensions = [".csv", ".txt", ".json", ".md"],
    onUnrecognised = "reject",
  } = options;

  const header = await readHeader(file, headerBytes);
  const match = matchHeader(header);
  const declared = normalise(file.type);
  const detected = match ? match.mime : null;

  const base = { name: file.name, size: file.size, declared, container: match?.container ?? false };

  if (!detected) {
    const isText = textExtensions.includes(extensionOf(file.name));
    return {
      ...base,
      detected: null,
      verdict: "unrecognised",
      accepted: isText || onUnrecognised === "defer",
      reason: isText
        ? `no binary signature, but "${extensionOf(file.name)}" is an allowed text format`
        : `first ${header.length} bytes match no known signature`,
    };
  }

  if (!allow.includes(detected)) {
    return {
      ...base,
      detected,
      verdict: declared === detected ? "confirmed" : "conflict",
      accepted: false,
      reason: `content is ${detected}, which is not in the allow list`,
    };
  }

  if (declared === "") {
    return { ...base, detected, verdict: "undeclared", accepted: true,
      reason: `the OS supplied no type; using ${detected} from the bytes` };
  }

  if (declared !== detected) {
    return { ...base, detected, verdict: "conflict", accepted: true,
      reason: `declared ${declared} but the bytes say ${detected}` };
  }

  return { ...base, detected, verdict: "confirmed", accepted: true,
    reason: `declared and detected type agree on ${detected}` };
}
Verdict matrix combining the declared type with the detected type A three by three grid crossing three states of the declared File.type against three outcomes of byte detection, giving the verdict and action for each combination. What to do when the two answers differ BYTES RECOGNISED BYTES RECOGNISED NO SIGNATURE on the allow list not on the allow list text, or truncated file.type agrees the common case confirmed sign the detected type blocked format not supported unrecognised pass if extension is text file.type conflicts renamed or remapped conflict accept, log the mismatch blocked strong abuse signal blocked nothing agrees file.type is empty no OS mapping undeclared supply the detected type blocked format not supported unrecognised defer to the server The bytes win every disagreement; the declared type only ever adds context to the log line.
Nine outcomes, four verdicts: the only cell that silently proceeds is the one where both sources already agree.

The conflict row is the one teams get wrong. A conflict where the detected type is allowed is usually not an attack — it is a Windows machine reporting application/vnd.ms-excel for a CSV, or an .avif saved with a .jpg extension by a well-meaning export script. Accepting it while recording the mismatch is the right behaviour. A conflict where the detected type is not allowed is the case worth escalating: payload.exe renamed to cv.pdf produces declared application/pdf, detected application/vnd.microsoft.portable-executable, and that log line belongs in whatever feeds your abuse tooling.

5. Wire the verdict into the UI and the upload request

Detection is only useful if it changes what happens next. Run it on change, render per-file state, and carry the detected type into the request that creates the upload.

// wire.ts
import { inspect, type Inspection } from "./inspect.js";

const ALLOW = ["image/jpeg", "image/png", "image/webp", "image/avif", "video/mp4"];

async function createUpload(file: File, result: Inspection): Promise<{ url: string }> {
  const res = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      name: file.name,
      size: file.size,
      declaredType: result.declared,
      detectedType: result.detected, // a claim the server re-verifies, not a decision
    }),
  });
  if (res.status === 415) {
    const body = (await res.json()) as { error: string };
    throw new Error(`server rejected the type: ${body.error}`);
  }
  if (!res.ok) throw new Error(`create upload failed: HTTP ${res.status}`);
  return (await res.json()) as { url: string };
}

const input = document.querySelector<HTMLInputElement>("#file");
const list = document.querySelector<HTMLUListElement>("#queue");

input?.addEventListener("change", async () => {
  const files = Array.from(input.files ?? []);
  if (list) list.textContent = "";

  const results = await Promise.all(
    files.map(async (file) => {
      try {
        return { file, result: await inspect(file, { allow: ALLOW }) };
      } catch (cause) {
        // NotReadableError, or a directory dragged in as if it were a file.
        return { file, result: null, cause: cause as DOMException };
      }
    }),
  );

  for (const entry of results) {
    const li = document.createElement("li");
    if (!entry.result) {
      li.textContent = `${entry.file.name} — could not be read (${entry.cause?.name})`;
    } else {
      const { verdict, detected, accepted, reason } = entry.result;
      li.dataset.verdict = verdict;
      li.textContent = `${entry.file.name}${detected ?? "unknown"} (${verdict}): ${reason}`;
      if (accepted) {
        const { url } = await createUpload(entry.file, entry.result);
        li.textContent += `${url}`;
      }
    }
    list?.appendChild(li);
  }
});

The detail that saves a support ticket later: use result.detected — not file.type — as the Content-Type you sign and send. If you issue a presigned PUT through S3 presigned URL workflows and sign a Content-Type the browser then fails to send because file.type was the empty string, S3 answers 403 with <Code>SignatureDoesNotMatch</Code> and the failure looks like a credentials problem rather than a MIME problem. Detected types are never empty, so signing them removes that class of bug entirely.

Configuration reference

Option Type Default Effect
headerBytes number 32 Bytes sliced from the front of the file. 12 covers the table above; 32 also reaches Matroska’s DocType. Raising it costs nothing measurable — one disk page is read either way.
allow readonly string[] Required. Detected MIME types you will accept. Compared against the detected value, never the declared one.
textExtensions readonly string[] [".csv", ".txt", ".json", ".md"] Extensions permitted to pass with no signature match. Adding .svg here means accepting executable content; sanitise it server-side if you do.
onUnrecognised "reject" | "defer" "reject" What happens when nothing matches and the extension is not text. "defer" uploads anyway and lets the server decide — correct when your allow list is long or your users bring unusual formats.
accept (HTML) string Picker filter only. Include both MIME and extension tokens; extension tokens require the leading dot or the browser silently discards them.
multiple (HTML) boolean false With Promise.all over the inspections, a 500-file selection still resolves in well under a second.
ISO brand refinement built in on Reads bytes 8–11 of an ftyp box to split HEIC, AVIF, QuickTime and 3GP out of the generic video/mp4 match.
container (result) boolean true for ZIP and unrefined ISO base media. Treat these as “wrapper identified, contents unknown” and never as a final answer.

Edge cases and gotchas

Zero-byte and truncated files

new File([], "empty.png") slices to a zero-length Uint8Array, and every DataView read against it throws RangeError: Offset is outside the bounds of the DataView. The header.length < p.mask.length guard in matchHeader handles this by declining to match rather than throwing, which yields unrecognised — the honest answer for a file that contains nothing. Zero-byte uploads are more common than they sound: a cancelled cloud-sync download, a file still being written by another process, or a Google Drive placeholder on Windows all produce them.

The ZIP container is a dead end in the browser

PK 03 04 tells you the file is a ZIP archive and nothing more. A .docx, an .apk and an .epub are indistinguishable at byte level, and the ZIP specification imposes no ordering on entries, so even reading the first entry name is a hint rather than proof. If your product accepts documents, set container: true on the result, allow it through with a soft verdict, and let the server open the archive. That is also where a decompression-ratio check belongs, because a 42 KB archive that expands to 4.5 GB is a problem no header read can see.

HEIC and AVIF from phones

An iPhone uploads IMG_0421.HEIC with ftyp at byte 4 and brand heic at byte 8. Match only the ftyp box and you will label it video/mp4 and route a photo to a video transcoder. Worse, Chrome and Firefox cannot decode HEIC in an <img> tag at all, so if you accept it you must transcode server-side or show the user a broken preview. The brand refinement in matchHeader exists precisely for this; keep headerBytes at 12 or above or the brand read silently falls out of range.

Text formats have no signature, and SVG is code

SVG, CSV and JSON produce unrecognised every time. Rejecting on that basis breaks legitimate imports; accepting on extension alone lets <svg onload="fetch('/api/keys')"> into your asset bucket. The workable split is: allow text extensions through client-side with accepted: true, then serve every user-supplied SVG from a separate origin with Content-Disposition: attachment and a restrictive Content-Security-Policy, or strip it to a raster image during preprocessing.

Directory entries and unreadable references

A folder dragged into a drop zone arrives as an entry whose arrayBuffer() rejects with NotReadableError: The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired. The same exception fires when a file was moved or renamed after the picker handed you the reference, or when it lives on a disconnected network share. Catch it separately from the detection result — it is an I/O failure, not a verdict — and handle real directories through the DataTransfer API.

Polyglots pass every check on this page

A file that is a valid GIF for the first hundred bytes and a ZIP archive from byte 200 satisfies the GIF signature completely. Prefixing %PDF- to arbitrary content makes it detect as a PDF. Header matching catches mistakes and lazy attackers; only decoding, re-encoding or scanning the file catches deliberate ones, which is why automated virus scanning sits after the object lands rather than before it.

Detection results are not authorisation

The detectedType field in the create-upload request is a claim, exactly like declaredType. A curl request never runs your JavaScript. The server must re-read the header from the stored object — or from the request body if you proxy — and compare against its own allow list, using libmagic-based signature validation in Node.js or an equivalent. Send the client’s opinion anyway: a request whose claimed and server-detected types disagree is a much sharper abuse signal than either value alone.

Verification

Run the detector against fixtures that cover the offset cases and the adversarial one. This block runs unchanged in a browser console with the modules imported, or under Node 20+ with node --input-type=module:

const bytes = (hex: string) =>
  new Uint8Array(hex.split(/\s+/).map((h) => Number.parseInt(h, 16)));

const PNG = bytes("89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52");
const WEBP = bytes("52 49 46 46 24 3A 01 00 57 45 42 50 56 50 38 20");
const HEIC = bytes("00 00 00 18 66 74 79 70 68 65 69 63 00 00 00 00");
const MZ = bytes("4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00");

const ALLOW = ["image/jpeg", "image/png", "image/webp", "image/avif", "video/mp4"];

// 1. Offset-8 case: RIFF alone is not enough, WEBP at byte 8 is.
const webp = await inspect(new File([WEBP], "shot.webp", { type: "image/webp" }), { allow: ALLOW });
console.assert(webp.detected === "image/webp" && webp.verdict === "confirmed", "webp");

// 2. ISO brand refinement: ftyp at byte 4, brand "heic" at byte 8.
const heic = await inspect(new File([HEIC], "IMG_0421.HEIC", { type: "" }), { allow: ALLOW });
console.assert(heic.detected === "image/heic", `expected image/heic, got ${heic.detected}`);
console.assert(!heic.accepted, "heic is not on this allow list, so it must be blocked");

// 3. Empty file.type still yields a usable Content-Type.
const png = await inspect(new File([PNG], "diagram.png", { type: "" }), { allow: ALLOW });
console.assert(png.verdict === "undeclared" && png.accepted, "undeclared png");
console.assert(png.detected === "image/png", "detected type replaces the empty declared type");

// 4. The case that matters: a PE binary wearing a PDF name and MIME type.
const evil = await inspect(new File([MZ], "cv.pdf", { type: "application/pdf" }), { allow: ALLOW });
console.assert(evil.verdict === "conflict" && !evil.accepted, "renamed executable must be blocked");
console.log(evil.reason);

// 5. Nothing to go on returns unrecognised, not a wrong guess.
const tiny = await inspect(new File([bytes("FF")], "t.bin", { type: "" }), { allow: ALLOW });
console.assert(tiny.verdict === "unrecognised" && !tiny.accepted, "1-byte file");

console.log("file type detection verified");

Case 4 prints content is application/vnd.microsoft.portable-executable, which is not in the allow list.

Then confirm the server does not take your word for it. Post a renamed executable straight to the endpoint, bypassing the browser entirely:

# Build a 16-byte PE header and hand it over as a PDF.
printf '\x4d\x5a\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00' > cv.pdf

curl -i -X POST https://api.example.com/uploads \
  -F "file=@cv.pdf;type=application/pdf"
# Expect: HTTP/1.1 415 Unsupported Media Type
# {"error":"declared application/pdf but content is application/x-dosexec"}

If that request returns 201, every check on this page is decoration. Finally, watch the read cost in DevTools: set a breakpoint on the line after readHeader and check the Network panel shows no request and the Memory panel shows no heap growth — a correct implementation allocates 32 bytes, not the file.

Frequently Asked Questions

Can I trust File.type at all?

Only as a hint to display or to log alongside the detected value. It comes from an extension lookup in a per-machine table, so it is wrong whenever a file is renamed and empty whenever the OS has no mapping — .heic, .mkv and .md all report "" on a stock Windows install. Use it for telemetry and never for a branch that matters.

Does the accept attribute stop the wrong file being uploaded?

No. It filters what the OS picker offers, and both Windows and macOS give the user a way past it in one click. The change event that follows a bypass is indistinguishable from a compliant one, so assume every selection has ignored the filter and re-check the bytes.

How many bytes do I need to read?

Twelve covers every signature in the table on this page, including the WebP form type and the ISO brand. Thirty-two is the comfortable default because it also reaches Matroska’s DocType and survives an ISO file with a leading free box. Reading more costs nothing measurable — the browser fetches at least a full disk page either way.

Why does my .docx detect as application/zip?

Because it is one. Office Open XML, ODF, APK, JAR and EPUB all begin with 50 4B 03 04, byte for byte. Treat the result as a container match, allow it through with a soft verdict if your product accepts documents, and identify it properly on the server by opening the archive.

If the server has to validate anyway, why detect in the browser?

Bandwidth and latency. Catching a 900 MB file with the wrong header before the transfer starts saves the user eight minutes on a mobile link and saves you the ingress cost and the storage lifecycle cleanup for an object you were always going to delete. It also produces the declared-versus-detected mismatch record, which is a useful abuse signal you cannot get any other way.