Verifying Shared File Types with Magic Bytes

This guide is part of Securing Incoming Shared Payloads, which itself is part of Web Share Target API.

When a share target receives a File, two properties describe what it supposedly is: name and type. Both are metadata the sender chose, and neither was checked by anything along the way. A file called holiday.png with type: 'image/png' may contain a PDF, an HTML document, or a zip archive. If your handler decides what to do based on those claims, it is taking instructions from the payload.

Reading the first few bytes settles it. Every common format begins with a fixed signature, and comparing that against an allow-list turns a claim into a fact.

Feature Detection Gate

Blob.prototype.slice and arrayBuffer are the only requirements, and both are universal wherever share targets exist.

// header-reader.js
export async function readHeader(file, byteCount = 16) {
  if (!(file instanceof Blob)) return null;
  if (file.size === 0) return null;

  // slice() does not read the file — only the requested range is fetched.
  const buffer = await file.slice(0, byteCount).arrayBuffer();
  return new Uint8Array(buffer);
}

Because slice returns a lazy view, this costs the same for a 40 MB video as for a 4 KB icon. There is no reason to skip the check on large files.

Claimed Type Versus Actual Bytes A file arrives named holiday.png and declared image/png. Its first four bytes are 25 50 44 46, the PDF signature, not 89 50 4E 47. The mismatch is detected before the content is handled, and the file is rejected rather than passed to an image pipeline. What the sender claims file.name = "holiday.png" file.type = "image/png" What the bytes say 25 50 44 46 → "%PDF" expected 89 50 4E 47 for PNG compare declared vs detected type-mismatch → rejected, logged, never handled as an image

Step 1 — Match Against a Signature Table

Keep the table small: it should list exactly the formats you accept, and nothing else.

// signatures.js
export const SIGNATURES = [
  { type: 'image/png',       offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { type: 'image/jpeg',      offset: 0, bytes: [0xff, 0xd8, 0xff] },
  { type: 'image/gif',       offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] },
  { type: 'image/webp',      offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },   // "WEBP" after RIFF
  { type: 'application/pdf', offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }
];

export function matchSignature(header) {
  return SIGNATURES.find(({ offset, bytes }) =>
    bytes.every((byte, i) => header[offset + i] === byte)
  )?.type ?? null;
}

WebP shows why the offset field exists: the file starts with RIFF, a four-byte length, and only then the format marker. A table that assumes every signature sits at byte zero silently rejects valid WebP images.

Step 2 — Treat a Mismatch as Hostile

There are three distinct outcomes, and collapsing them loses the signal that matters.

// verify-file.js
import { readHeader } from './header-reader.js';
import { matchSignature } from './signatures.js';

const ACCEPTED = new Set(['image/png', 'image/jpeg', 'image/webp']);

export async function verifyFile(file) {
  const header = await readHeader(file);
  if (!header) return { ok: false, reason: 'unreadable' };

  const detected = matchSignature(header);
  if (!detected) return { ok: false, reason: 'unrecognised-format' };
  if (!ACCEPTED.has(detected)) return { ok: false, reason: 'format-not-accepted', detected };

  if (file.type && file.type !== detected) {
    // The sender said one thing and the bytes say another — deliberate, not accidental.
    return { ok: false, reason: 'type-mismatch', declared: file.type, detected };
  }

  return { ok: true, type: detected };
}

unrecognised-format usually means an honest user shared something you do not support. type-mismatch almost never happens by accident, so it is the one worth counting and alerting on: a rise in that rate is somebody probing the endpoint.

Step 3 — Never Let the Filename Decide Anything

The name is display metadata. It must not become a storage key, a path segment, or the basis of a type decision.

// safe-storage.js
import { verifyFile } from './verify-file.js';

const EXTENSION_FOR = {
  'image/png': 'png',
  'image/jpeg': 'jpg',
  'image/webp': 'webp'
};

export async function prepareForStorage(file) {
  const verdict = await verifyFile(file);
  if (!verdict.ok) return verdict;

  // Our identifier, our extension — derived from the VERIFIED type.
  const id = crypto.randomUUID();
  const storedName = `${id}.${EXTENSION_FOR[verdict.type]}`;

  return {
    ok: true,
    id,
    storedName,
    type: verdict.type,
    displayName: displaySafe(file.name),      // shown to the user, never used as a path
    bytes: file.size
  };
}

function displaySafe(name) {
  return String(name ?? '')
    .replace(/[\u0000-\u001F\u007F]/g, '')  // control characters
    .replace(/[\\/]/g, '')                    // no path separators, ever
    .slice(0, 120) || 'shared file';
}

Two failure classes close here. Path separators and traversal sequences in a filename cannot reach a filesystem or a cache key, because the stored name is generated. And the extension comes from the verified type rather than from the original name, so a mislabelled file cannot be re-served with a type that changes how a browser interprets it.

Step 4 — Re-Encode Anything You Will Re-Serve

Verification proves the file starts like an image. If you will serve those bytes back to other users, re-encoding removes everything else the container might carry — trailing appended data, embedded metadata, and format quirks that different decoders disagree about.

// reencode.js
export async function reencodeImage(file, type = 'image/webp', quality = 0.9) {
  const bitmap = await createImageBitmap(file);        // throws on malformed input
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  canvas.getContext('2d').drawImage(bitmap, 0, 0);
  bitmap.close();

  const blob = await canvas.convertToBlob({ type, quality });
  return new File([blob], `reencoded.${type.split('/')[1]}`, { type });
}

The decode step is itself a verification: createImageBitmap rejects input the platform decoder cannot parse, so a file that survives it is genuinely an image rather than merely something starting with an image header.

Layers of File Verification Four layers applied in increasing cost order: a size cap that reads nothing, a magic-byte check that reads sixteen bytes, a declared-versus-detected comparison, and finally a full decode and re-encode for content that will be served back to other users. 1 · size cap — reads zero bytes rejects resource exhaustion before any work happens 2 · magic bytes — reads 16 bytes proves the file starts as the format it claims; effectively free 3 · declared vs detected — no extra read a mismatch is a deliberate probe, not a mistake — count it 4 · decode + re-encode — reads everything only for content you will serve back; strips trailing data and metadata Signatures Do Not All Start at Byte Zero PNG, JPEG and PDF signatures begin at the first byte, but a WebP file starts with RIFF and a four-byte length, so its format marker sits at byte eight. A table that assumes offset zero silently rejects valid WebP images. PNG offset 0 89 50 4E 47 0D 0A 1A 0A JPEG offset 0 FF D8 FF PDF offset 0 25 50 44 46 ("%PDF") WebP offset 8 57 45 42 50 ("WEBP" after RIFF + length)

Failure Modes and Recovery

Symptom Cause Minimal fix
Non-image handled as an image Trusted file.type Verify the header before use
Valid WebP rejected Signature assumed at offset 0 Match WEBP at byte 8
Check skipped for large files Assumed the read was expensive file.slice(0, 16) reads only the range
Storage key collides or escapes Sender filename used as a key Generate an identifier; strip separators
Re-served file interpreted oddly Extension taken from the original name Derive it from the verified type
Appended payload survives upload No re-encode step Decode and re-encode before serving
Empty file crashes the check Zero-length input Return early when file.size === 0

Browser and Platform Caveat

Blob.slice, arrayBuffer and crypto.randomUUID are available in every browser that supports share targets, and the read is genuinely lazy, so this check costs the same regardless of file size. OffscreenCanvas and convertToBlob are current in Chrome, Edge and Safari; where they are missing, a detached <canvas> with toBlob performs the same re-encode. One platform note worth carrying: iOS shares can arrive as image/heic, which has its own signature at an offset and which many pipelines cannot decode — either add it to the accepted table and convert, or reject it clearly rather than letting it fall into the unrecognised-format bucket where the user gets no useful explanation. Since share targets are Chromium-only today, the practical exposure is Android and ChromeOS, but the endpoint remains reachable from any browser by URL, so none of these checks may be made conditional on share-target support.

FAQ

Does the browser verify a shared file’s MIME type?

No. The type on a File object is metadata the sender set, usually guessed from the extension, and nothing in the share pipeline inspects the bytes. Verification is entirely the receiving application’s responsibility.

How many bytes do I need to read?

Twelve is enough for the common image and document formats, and a few more for container formats such as MP4 whose marker sits at an offset. Because file.slice does not load the whole file, the check is effectively free even for very large files.

Is magic-byte checking enough on its own?

It proves the file starts like the format it claims, which stops type confusion, but it is not a malware scan and it does not prove the rest of the file is well formed. Combine it with a size cap, an allow-list of accepted formats, and re-encoding anything you will re-serve.