Detecting File Type from Magic Bytes in JavaScript

Slice the first 32 bytes of the File, convert them to a Uint8Array with arrayBuffer(), and compare them against a table of (offset, byte-sequence) signatures — that identifies JPEG, PNG, GIF, WebP, MP4, PDF and ZIP in well under a millisecond and catches a renamed .exe before a single byte leaves the browser.

This article sits inside file type detection in the browser, part of upload fundamentals and browser APIs. The reason it exists: file.type is filled in by the operating system’s extension-to-MIME map, so it is a hint supplied by whoever named the file, not evidence about its contents.

When to use this approach

  • You want to reject an obviously wrong file before starting the transfer — worth doing for anything over a few megabytes, and essential when a mistake costs a user eight minutes of mobile upload.
  • The declared type is unusable: file.type is "" for any extension the OS has no mapping for, and Windows still reports application/octet-stream for plenty of media formats.
  • You need to disambiguate look-alikes locally — .heic renamed to .jpg, an .avif that a <img> tag will refuse to decode, a .docx that is really an .apk.

Do not use it as your only check. Magic-byte matching in the browser is a UX filter; a client that skips your JavaScript entirely still reaches your endpoint. The authoritative check belongs on the server, with libmagic-based signature validation in Node.js or an equivalent.

Prerequisites

  1. A File or Blob reference from an <input type="file">, a drop zone, or the clipboard.
  2. TypeScript with lib: ["DOM", "DOM.Iterable", "ES2022"], or plain ESM in any current browser.
  3. Blob.prototype.arrayBuffer(), which has been available in Chrome 76+, Firefox 69+ and Safari 14+. On anything older, fall back to FileReader and ArrayBuffer.
  4. No dependencies. The whole detector below is about 60 lines and adds nothing to your bundle beyond that.

Implementation

The detector has three moving parts: a bounded read of the header, a signature table where every entry names its own offset, and a matcher that tries the most specific signature first.

// magic-bytes.ts — dependency-free file-type detection from header bytes.

/** 32 covers every signature below, with room for a longer table later. */
const HEADER_BYTES = 32;

export interface ByteRule {
  /** Byte position where this sequence must start. */
  readonly offset: number;
  readonly bytes: readonly number[];
}

export interface Signature {
  readonly mime: string;
  readonly ext: string;
  /** ALL rules must match for the signature to apply. */
  readonly rules: readonly ByteRule[];
  /** True when the bytes identify a container, not a final format. */
  readonly container?: boolean;
}

const ascii = (text: string): number[] => [...text].map((c) => c.charCodeAt(0));

export const SIGNATURES: readonly Signature[] = [
  { mime: "image/jpeg", ext: "jpg", rules: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }] },
  {
    mime: "image/png",
    ext: "png",
    rules: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
  },
  { mime: "image/gif", ext: "gif", rules: [{ offset: 0, bytes: ascii("GIF87a") }] },
  { mime: "image/gif", ext: "gif", rules: [{ offset: 0, bytes: ascii("GIF89a") }] },
  // WebP is a RIFF container: "RIFF", four size bytes, then "WEBP" at byte 8.
  {
    mime: "image/webp",
    ext: "webp",
    rules: [
      { offset: 0, bytes: ascii("RIFF") },
      { offset: 8, bytes: ascii("WEBP") },
    ],
  },
  // ISO base media: bytes 0-3 are the box length, so "ftyp" starts at byte 4.
  { mime: "video/mp4", ext: "mp4", rules: [{ offset: 4, bytes: ascii("ftyp") }] },
  { mime: "application/pdf", ext: "pdf", rules: [{ offset: 0, bytes: ascii("%PDF-") }] },
  // Every OOXML/OPC document, APK, JAR and EPUB starts with these four bytes.
  {
    mime: "application/zip",
    ext: "zip",
    container: true,
    rules: [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }],
  },
  {
    mime: "application/vnd.microsoft.portable-executable",
    ext: "exe",
    rules: [{ offset: 0, bytes: [0x4d, 0x5a] }],
  },
  { mime: "application/x-elf", ext: "elf", rules: [{ offset: 0, bytes: [0x7f, 0x45, 0x4c, 0x46] }] },
];

const weight = (sig: Signature): number =>
  sig.rules.reduce((total, rule) => total + rule.bytes.length, 0);

/** Most specific first, so an 8-byte match always beats a 2-byte one. */
const ORDERED = [...SIGNATURES].sort((a, b) => weight(b) - weight(a));

function ruleMatches(header: Uint8Array, rule: ByteRule): boolean {
  if (header.length < rule.offset + rule.bytes.length) return false;
  for (let i = 0; i < rule.bytes.length; i++) {
    if (header[rule.offset + i] !== rule.bytes[i]) return false;
  }
  return true;
}

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

export function detectFromHeader(header: Uint8Array): Detection | null {
  for (const sig of ORDERED) {
    if (sig.rules.every((rule) => ruleMatches(header, rule))) {
      return { mime: sig.mime, ext: sig.ext, container: sig.container === true };
    }
  }
  return null;
}

/** Reads at most HEADER_BYTES from disk — cost is independent of file size. */
export async function readHeader(file: Blob): Promise<Uint8Array> {
  const head = file.slice(0, HEADER_BYTES);
  return new Uint8Array(await head.arrayBuffer());
}

export async function detectFileType(file: Blob): Promise<Detection | null> {
  return detectFromHeader(await readHeader(file));
}

Wiring it to an input is four lines:

const input = document.querySelector<HTMLInputElement>("#file")!;
input.addEventListener("change", async () => {
  const file = input.files?.[0];
  if (!file) return;
  const found = await detectFileType(file);
  console.log(file.name, "declared:", file.type || "(none)", "actual:", found?.mime ?? "unknown");
});

Line-by-line on the critical parts

  • file.slice(0, HEADER_BYTES) creates a Blob view; no I/O happens yet. The read is triggered by arrayBuffer(), and it pulls one page from disk whether the file is 4 KB or 4 GB. Measured on a mid-range laptop, header detection on a 2 GB MKV takes roughly 0.3 ms. This is the same slicing primitive described in slicing large files with Blob.slice, just applied to the front of the file instead of to chunk boundaries.
  • new Uint8Array(buffer) is what makes byte comparison possible. An ArrayBuffer has no indexer; buffer[0] is undefined, not 0x89, which is the single most common reason a first attempt at this silently matches nothing.
  • HEADER_BYTES = 32 is deliberately larger than the longest rule here (12 bytes, for WebP). Matroska’s DocType string sits near byte 24, and ISO base-media files with a leading free box push ftyp past byte 8, so 32 buys headroom for free — you are paying for one disk page regardless.
  • ORDERED sorts by total matched bytes descending. That ordering matters the moment your table grows: audio/wav and video/x-msvideo are also RIFF files whose rule is just RIFF at offset 0. Sorted by specificity, WebP’s 8 matched bytes win; unsorted, whichever entry you happened to declare first wins.
  • The header.length guard in ruleMatches distinguishes “too short to tell” from “does not match”. Indexing past the end of a Uint8Array yields undefined, which compares unequal and would coincidentally return false — but relying on that makes a later DataView read blow up instead.
  • container: true on ZIP is the honest part of the API. The function tells you “these bytes are a ZIP local file header”, not “this is a .zip”. Everything downstream must treat that differently from a definite image/png.

Offsets are part of the signature

A signature table keyed only by “the first N bytes” cannot express two of the most common upload formats. WebP and MP4 both put their identifying string behind a length or a container header, so the offset has to travel with the bytes.

Byte anatomy of a WebP file header The first sixteen bytes of a WebP file split into four groups: the RIFF magic at bytes zero to three, a varying little-endian size at four to seven, the WEBP form type at eight to eleven, and the first chunk identifier at twelve to fifteen. The first 16 bytes of a WebP file byte 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 52 49 46 46 24 3A 01 00 57 45 42 50 56 50 38 20 R I F F not text W E B P V P 8 RIFF magic size − 8 (LE) WEBP form first chunk constant varies per file constant VP8 / VP8L / VP8X A zero-anchored matcher sees only RIFF — which is also AVI and WAV. The file is WebP only because bytes 8–11 spell WEBP.
Four bytes of the WebP header vary per file, so the format string had to be pushed to offset 8 — a matcher that only reads from byte 0 cannot see it.

Laid out across all the formats in the table, the pattern is easy to read: most signatures live at byte 0, and the two that do not are precisely the ones a naive implementation gets wrong.

Signature positions for seven common upload formats A byte ruler from zero to eleven with one row per format, showing that JPEG, PNG, GIF, PDF and ZIP match at offset zero while WebP needs offsets zero and eight and MP4 matches at offset four. Where each signature lives 0 1 2 3 4 5 6 7 8 9 10 11 JPEG PNG GIF PDF ZIP WebP MP4 FF D8 FF 89 50 4E 47 0D 0A 1A 0A GIF87a / GIF89a %PDF- PK 03 04 RIFF WEBP box size ftyp brand Only WebP and MP4 need a non-zero offset — and they are two of the most uploaded formats.
Five formats match at byte 0; WebP needs two anchored rules and MP4 matches at byte 4, which is why the signature type carries an offset rather than assuming zero.
Format Offset Bytes (hex) ASCII
JPEG 0 FF D8 FF
PNG 0 89 50 4E 47 0D 0A 1A 0A .PNG....
GIF 0 47 49 46 38 37 61 / …38 39 61 GIF87a / GIF89a
WebP 0 and 8 52 49 46 46 + 57 45 42 50 RIFF + WEBP
MP4 / MOV / AVIF 4 66 74 79 70 ftyp
PDF 0 25 50 44 46 2D %PDF-
ZIP and friends 0 50 4B 03 04 PK..
Windows PE 0 4D 5A MZ

Refining the ISO base-media family

ftyp at byte 4 tells you the file is ISO base media — which covers MP4, MOV, AVIF, HEIC and 3GP, formats with wildly different handling. The four bytes at offset 8 are the major brand, and that is what separates them:

const ISO_BRANDS: Record<string, string> = {
  avif: "image/avif",
  avis: "image/avif",
  heic: "image/heic",
  heix: "image/heic",
  mif1: "image/heif",
  "3gp4": "video/3gpp",
  "3gp5": "video/3gpp",
  qt: "video/quicktime",
  M4A: "audio/mp4",
  M4V: "video/x-m4v",
};

export function refineIsoBmff(header: Uint8Array): string {
  // Brands are four ASCII characters, right-padded with spaces ("qt  ").
  const brand = new TextDecoder("latin1").decode(header.subarray(8, 12)).trim();
  return ISO_BRANDS[brand] ?? "video/mp4";
}

This matters in practice: an iPhone photo uploaded as IMG_0421.HEIC has brand heic, and Chrome and Firefox cannot decode it in an <img> tag at all — you either transcode it or you show a broken preview. An .avif misreported as video/mp4 gets routed to a video transcoder that will reject it.

One more wrinkle for QuickTime-derived files: the specification says ftyp should be the first box, but real files sometimes carry a free, skip or wide box in front of it. Walk the box chain instead of hard-coding offset 4:

export function findFtypOffset(header: Uint8Array): number {
  const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
  const decoder = new TextDecoder("latin1");
  let pos = 0;
  while (pos + 8 <= header.byteLength) {
    const boxType = decoder.decode(header.subarray(pos + 4, pos + 8));
    if (boxType === "ftyp") return pos + 4;
    if (boxType !== "free" && boxType !== "skip" && boxType !== "wide") return -1;
    const boxSize = view.getUint32(pos); // big-endian, per ISO/IEC 14496-12
    if (boxSize < 8) return -1; // 0 or 1 mean "to end of file" / 64-bit size
    pos += boxSize;
  }
  return -1;
}

The ZIP container problem

Here is where honest client-side detection stops. PK\x03\x04 is a ZIP local file header, and a very large slice of modern file formats are ZIP archives with a naming convention on top: .docx, .xlsx, .pptx, .odt, .apk, .jar, .epub, .war, .ipa, .kmz. Their first four bytes are byte-for-byte identical. No amount of header reading distinguishes them.

One ZIP signature, four possible file types The PK signature at byte zero resolves only to a ZIP local file header, below which a dashed line marks the limit of client-side detection; four candidate formats branch beneath it, each identified by its first entry name. One signature, four answers 50 4B 03 04 at byte 0 ZIP local file header limit of header-only detection — everything below needs the entry list .docx / .xlsx [Content_Types] .apk / .jar META-INF/ .epub mimetype (stored) plain .zip anything at all Entry order is not fixed by the ZIP format, so the first name is a hint, never proof.
Header bytes take you as far as "this is a ZIP"; separating a document from an Android package requires reading entry names, and even that is only a hint.

You can go one step further without a ZIP library, because the local file header stores the entry name inline: a 16-bit little-endian name length at offset 26, and the name itself starting at offset 30.

export async function peekZipFirstEntry(file: Blob): Promise<string | null> {
  const head = new Uint8Array(await file.slice(0, 512).arrayBuffer());
  if (head.length < 30) return null;
  const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
  const nameLength = view.getUint16(26, true); // little-endian, per APPNOTE 4.4.10
  const end = 30 + nameLength;
  if (end > head.length) return null;
  return new TextDecoder().decode(head.subarray(30, end));
}

Word documents written by Office start with [Content_Types].xml, EPUBs are required by spec to store an uncompressed mimetype entry first, and Android packages usually lead with META-INF/MANIFEST.MF. But the ZIP format imposes no ordering at all — a .docx produced by a Python library may put _rels/.rels first, and an attacker can reorder entries freely. Treat the entry name as a UX hint that lets you say “this looks like a spreadsheet, not a document”, and let the server open the archive properly, ideally alongside automated virus scanning.

How far client-side detection honestly goes

Four hard limits are worth stating plainly before you build a policy on top of this:

  1. It is not a security boundary. Your JavaScript runs on the attacker’s machine. A curl request against your presigned URL never executes any of it. Everything here has to be repeated by server-side file validation that the client cannot skip.
  2. Matching a header is not parsing a file. A polyglot — a valid GIF header with a payload appended after the image data — passes every check in this article. Only decoding or re-encoding the file proves it is what it claims.
  3. Container formats need their contents read. ZIP, ISO base media, RIFF and OLE2 (D0 CF 11 E0, the legacy .doc/.xls format) all identify the wrapper, not the payload.
  4. Text formats have no signature. SVG, CSV, JSON and plain text start with arbitrary bytes, optionally behind a UTF-8 BOM (EF BB BF). If you reject everything detectFileType returns null for, you have just blocked every CSV import in your product. Allow known-text extensions through by declared type and sanitise them server-side — SVG in particular is executable content.

What it buys you is real, though: instant feedback on a genuine user mistake, no wasted bandwidth on a file the server will reject anyway, and a recorded mismatch between the declared and detected type that is a useful abuse signal in your logs.

Configuration gotchas

NotReadableError: The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired. — Chrome throws this DOMException from arrayBuffer() when the file was moved, renamed or modified after the picker handed you the reference, when it lives on a disconnected network share, or when the “file” is actually a directory that was dragged in. Wrap the read in try/catch, prompt the user to re-select, and handle directory entries with the DataTransfer API rather than treating them as files. Never fold this into your “unknown type” branch — it is an I/O failure, not a detection result.

RangeError: Array buffer allocation failed — calling await file.arrayBuffer() on the whole file to inspect its first bytes allocates the entire file in the JS heap. A 2 GB video throws this in Chrome; on iOS Safari the tab is killed outright with no catchable error. Always slice(0, 32) first. This is the same discipline that governs uploads of large files generally.

RangeError: Offset is outside the bounds of the DataView — reading view.getUint32(0) or view.getUint16(26, true) on a header shorter than the requested offset plus width. A zero-byte file (easy to produce: new File([], "empty.png")) slices to a zero-length buffer and every DataView read throws. Guard on header.byteLength before any DataView access, as peekZipFirstEntry does.

Silent misclassification from table order. If you add audio/wav as a bare RIFF rule at offset 0 and it is tested before WebP, every .webp upload is reported as audio/wav — no exception, no warning, just a 415 Unsupported Media Type from your API an hour later. Sorting by matched-byte count as ORDERED does prevents it structurally; if you hand-order the table instead, add a test that asserts a WebP fixture resolves to image/webp.

Verification

This snippet runs unchanged in a browser console or under Node 20+ with node --input-type=module, and proves the case that matters — a Windows executable renamed to invoice.pdf and re-declared as application/pdf is still caught:

const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
                            0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]);
const MZ = new Uint8Array([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00,
                           0x04, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00]);
const WEBP = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0x24, 0x3a, 0x01, 0x00,
                             0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20]);

// 1. A renamed executable is caught despite a plausible name and MIME type.
const disguised = new File([MZ], "invoice.pdf", { type: "application/pdf" });
const found = await detectFileType(disguised);
console.assert(found?.mime === "application/vnd.microsoft.portable-executable",
  `expected a PE binary, got ${found?.mime ?? "null"}`);
console.assert(found?.mime !== disguised.type, "declared type must not be trusted");

// 2. Honest files still resolve correctly, including the offset-8 case.
console.assert((await detectFileType(new File([PNG], "a.png")))?.mime === "image/png", "png");
console.assert((await detectFileType(new File([WEBP], "b.webp")))?.mime === "image/webp", "webp");

// 3. Too few bytes to decide returns null rather than a wrong guess.
console.assert((await detectFileType(new File([new Uint8Array([0xff])], "t.bin"))) === null,
  "a 1-byte file must not match anything");

console.log("magic-byte detection verified");

Enforce it at the point of upload with a helper that fails loudly:

export async function assertAllowed(file: File, allowed: readonly string[]): Promise<Detection> {
  const found = await detectFileType(file);
  if (!found) throw new Error(`unrecognised file: "${file.name}" (${file.size} bytes)`);
  if (!allowed.includes(found.mime)) {
    throw new Error(
      `file-type mismatch: "${file.name}" declares ${file.type || "(none)"} ` +
        `but its bytes say ${found.mime}`,
    );
  }
  return found;
}

Running assertAllowed(disguised, ["application/pdf"]) throws exactly: file-type mismatch: "invoice.pdf" declares application/pdf but its bytes say application/vnd.microsoft.portable-executable.

Frequently Asked Questions

How many bytes do I actually need to read?

Twelve covers everything in the table above, and 32 is a comfortable default that also reaches Matroska’s DocType and ISO base-media files with a leading free box. Reading more costs nothing measurable — the browser fetches at least one disk page either way — so pick 32 or 64 and stop thinking about it.

Can magic bytes be faked?

Yes, trivially. Prefixing %PDF- to any payload makes it detect as a PDF, and polyglot files that are simultaneously valid in two formats are a well-known technique. Header matching catches mistakes and lazy attackers; only decoding the file, or scanning it, catches deliberate ones.

Why does my .docx detect as application/zip?

Because it is a ZIP archive — Office Open XML, ODF, APK, JAR and EPUB all begin with 50 4B 03 04. The first four bytes cannot separate them. Read the first entry name from the local file header for a hint, and confirm the real type on the server by opening the archive.

Should I still set the accept attribute if I do this?

Yes — restricting the picker with accept means users only see relevant files, which beats letting them choose one and then rejecting it. Magic-byte detection is the check that runs after they have chosen; the two solve different halves of the problem.

Does this work on a Blob from fetch or the clipboard?

Yes — detectFileType takes a Blob, and File extends Blob. Anything with .slice() and .arrayBuffer() works, including a Blob assembled from a canvas export or pulled off a paste event.