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.
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.
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.