Why Browser MIME Types Are Unreliable
File.type is derived from the filename extension through an operating-system lookup table — it never reads a single byte of the file — so treat it as a UX hint, sniff magic bytes for a fast client-side signal, and let server-side content inspection be the only authority.
This article sits inside file type detection in the browser, part of upload fundamentals and browser APIs. It explains where the value comes from, the three ways it goes wrong in production, and what to do instead.
When to use this approach
- You are writing an upload validator and are deciding how much weight to give
file.typeor theContent-Typeon an incoming multipart part. - Users report that a valid file is greyed out in the file picker, or that your “images only” rule accepts something that is plainly not an image.
- You are hardening an endpoint that stores user content and later serves it back from a domain your session cookies are scoped to.
Prerequisites
- Node 20+ if you want to run the server examples;
npm i express@4 multer@1 file-type@19. - TypeScript with
lib: ["DOM", "DOM.Iterable", "ES2022"]for the browser snippet. - A
Filereference from an<input type="file">, a drag-and-drop drop zone, or a clipboard paste.
Where File.type actually comes from
The File API spec says the user agent “should” set type to the file’s MIME type and offers no algorithm. Every mainstream engine implements the same shortcut: split the filename on the last dot, look the extension up in a table, return whatever string comes back. Chromium checks its built-in kPrimaryMappings list first (that is why .png is reliably image/png there), then falls through to the platform. Firefox and Safari go to the platform immediately. The platform means HKEY_CLASSES_ROOT\.<ext>\Content Type on Windows, LaunchServices’ Uniform Type Identifier table on macOS, and the freedesktop shared-mime-info glob database on Linux.
All three are mutable. Installing Microsoft Office rewrites the .csv registry key. Installing a photo utility can put image/x-png back under .png. A Windows 10 machine without the HEIF Image Extensions package has no entry for .heic at all, and an absent entry means an empty string, not a fallback.
The three failure modes
Everything that goes wrong with file.type reduces to one of three cases, and each one bites a different part of your stack.
Field values for the same extensions across desktops, all measured from input.files[0].type:
| Extension | Windows 10, Office installed | macOS 14 | Ubuntu 24.04 |
|---|---|---|---|
.png |
image/png |
image/png |
image/png |
.csv |
application/vnd.ms-excel |
text/csv |
text/csv |
.heic |
"" |
image/heic |
image/heic |
.md |
"" |
text/markdown |
text/markdown |
.svg |
image/svg+xml |
image/svg+xml |
image/svg+xml |
.mkv |
"" |
video/x-matroska |
video/x-matroska |
The empty string is the case most teams forget. A Blob created without a type option also reports "", and so does every part of a folder dropped via the DataTransfer API whose extension the machine does not know. When file.type is "" and you append the file to a FormData, the multipart part is emitted with Content-Type: application/octet-stream — see multipart form data explained for the exact wire format.
Implementation
The useful client-side move is not to trust file.type, and not to discard it either, but to reconcile it against the leading bytes and surface the disagreement. Sixteen bytes is enough for every common container signature; if you need the full offset-aware table, detecting file type from magic bytes in JavaScript covers it in depth.
// file-inspect.ts — reconcile what the OS claims with what the bytes say.
export interface Inspection {
name: string;
declared: string; // File.type, straight from the OS extension table
sniffed: string | null; // from the leading bytes: a strong hint, not proof
verdict: "match" | "mismatch" | "unknown" | "undeclared";
}
interface Signature {
mime: string;
offset: number;
bytes: number[];
}
const SIGNATURES: Signature[] = [
{ mime: "image/png", offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ mime: "image/jpeg", offset: 0, bytes: [0xff, 0xd8, 0xff] },
{ mime: "image/gif", offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] },
{ mime: "application/pdf", offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] },
{ mime: "image/webp", offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] }, // "WEBP" after RIFF
{ mime: "video/mp4", offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] }, // "ftyp" box
{ mime: "application/zip", offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] },
];
// Platform-specific spellings of the same real type.
const ALIASES = new Map<string, string>([
["image/x-png", "image/png"], // legacy Windows registry entry
["image/pjpeg", "image/jpeg"],
["audio/mp3", "audio/mpeg"],
["application/x-zip-compressed", "application/zip"], // Windows
["application/vnd.ms-excel", "text/csv"], // Windows with Office installed
]);
function normalise(raw: string): string {
const base = raw.split(";")[0].trim().toLowerCase();
return ALIASES.get(base) ?? base;
}
export async function inspect(file: File): Promise<Inspection> {
const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
const sniffed =
SIGNATURES.find((sig) => sig.bytes.every((b, i) => head[sig.offset + i] === b))?.mime ?? null;
const declared = normalise(file.type);
let verdict: Inspection["verdict"];
if (declared === "") verdict = "undeclared";
else if (sniffed === null) verdict = "unknown";
else verdict = normalise(sniffed) === declared ? "match" : "mismatch";
return { name: file.name, declared: file.type, sniffed, verdict };
}
Wire it to an input and you get an honest picture of every selected file:
const input = document.querySelector<HTMLInputElement>("#file")!;
input.addEventListener("change", async () => {
for (const file of Array.from(input.files ?? [])) {
const result = await inspect(file);
console.log(result);
if (result.verdict === "mismatch") {
console.warn(`${file.name}: OS says ${result.declared}, bytes say ${result.sniffed}`);
}
}
});
Line-by-line on the critical parts
file.slice(0, 16)reads only the header. Slicing returns aBlobview with no copy, so this costs one 16-byte disk read regardless of whether the file is 4 KB or 4 GB — the same technique described in slicing large files with Blob.slice.head[sig.offset + i] === bcompares againstundefinedfor a file shorter than the signature, so a truncated upload simply fails to match rather than throwing.- The
offset: 8entry for WebP exists because the first four bytes areRIFFand bytes 4–7 are the little-endian file length; only bytes 8–11 carryWEBP. MP4 is the same shape — theftypbox name starts at offset 4. normalisestrips any; charset=parameter before comparing. Some Linux desktops returntext/csv; charset=utf-8fromfile.type, and a naive===againsttext/csvfails on it.- The
ALIASESmap is the only place platform quirks live. When a new one turns up in your logs, you add one line rather than scattering||conditions through the validator. verdict: "unknown"is deliberately not an error. Plenty of legitimate formats — CSV, plain text, SVG, most subtitle formats — have no magic number at all, so absence of a signature proves nothing.
Report the verdict in the UI as advice (“this file looks like a PNG but is named .jpg”) and still upload it. The client is not the place to make the final ruling.
Why a server-side Content-Type check is not a check
Here is the bug, in the shape it usually ships:
// server/vulnerable.mjs — DO NOT SHIP. This is the bug.
import express from "express";
import multer from "multer";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const app = express();
const s3 = new S3Client({ region: "eu-west-1" });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
app.post("/upload", upload.single("file"), async (req, res) => {
// req.file.mimetype is copied verbatim from the multipart part header.
if (!req.file.mimetype.startsWith("image/")) {
return res.status(415).json({ error: "images only" });
}
await s3.send(
new PutObjectCommand({
Bucket: "user-uploads",
Key: `u/${req.body.userId}/${req.file.originalname}`,
Body: req.file.buffer,
ContentType: req.file.mimetype, // attacker-chosen, now persisted
}),
);
res.status(201).json({ ok: true });
});
app.listen(3000);
req.file.mimetype is not something the browser computed. It is the Content-Type header of the multipart part, and an attacker does not need a browser to set it:
# 70 bytes: a single `svg` root element carrying an onload handler.
base64 -d > pic.svg <<'EOF'
PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIG9ubG9hZD0iZmV0Y2goJy9h
cGkva2V5cycpIi8+
EOF
curl -s -F 'file=@pic.svg;type=image/svg+xml' -F 'userId=42' http://localhost:3000/upload
# {"ok":true}
image/svg+xml starts with image/, so the filter passes. The object is stored with that content type and, when it is later served from a hostname your session cookie covers, the browser renders it as a document and runs the script. That is stored cross-site scripting delivered through an upload form, and no amount of client-side checking would have stopped it, because the client was never involved.
The corrected handler ignores the declared type entirely for control-flow decisions and derives everything from the buffer:
// server/upload.mjs — the fixed version.
import express from "express";
import multer from "multer";
import { fileTypeFromBuffer } from "file-type";
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
// Exact matches only. A "starts with image/" rule lets image/svg+xml through.
const ALLOWED = new Map([
["image/jpeg", ".jpg"],
["image/png", ".png"],
["image/webp", ".webp"],
]);
app.post("/upload", upload.single("file"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "no file part" });
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected || !ALLOWED.has(detected.mime)) {
console.warn("rejected", {
declared: req.file.mimetype,
detected: detected?.mime ?? null,
name: req.file.originalname,
});
return res.status(415).json({
error: `declared ${req.file.mimetype} but content is ${detected?.mime ?? "unrecognised"}`,
});
}
// Store the DETECTED type and a server-generated key. Never the declared type,
// never the client's filename.
res.status(201).json({ storedAs: detected.mime, ext: ALLOWED.get(detected.mime) });
});
app.listen(3000);
file-type deliberately has no SVG detector, because SVG is text with no magic number — so the SVG payload above falls into !detected and returns 415. For a native libmagic equivalent with streaming support, see validating file signatures with libmagic in Node.js; for the full pipeline it belongs to, see server-side file validation.
The layering that works
Three layers, each with a different job and a different amount of trust. Getting this wrong usually means someone collapsed all three into one.
Two rules keep the layering honest. First, never make layer 1 or layer 2 the reason a request is rejected on the server — if the server would have accepted it anyway, the client check was advice; if it would not, the server check is what saved you. Second, always widen the accept attribute with extension tokens alongside MIME tokens, because an extension token is matched against the filename and is therefore immune to the registry problem.
Configuration gotchas
Empty file.type breaks a presigned PUT that signed a Content-Type. S3 returns 403 with <Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message> because the browser sent no Content-Type header while the signature covered one. Either omit ContentType when generating the presigned URL, or send file.type || "application/octet-stream" and sign the same fallback.
A POST policy condition fails on the empty string. With a browser form POST you get 400 and <Code>AccessDenied</Code><Message>Invalid according to Policy: Policy Condition failed: ["starts-with", "$Content-Type", "image/"]</Message>. The fix is the same fallback, or dropping the condition and validating server-side after the object lands.
accept="text/csv" hides CSV files on Windows. There is no error and no console warning — the file is simply not selectable, and the bug report reads “your uploader is broken”. Write accept=".csv,text/csv,application/vnd.ms-excel" so the extension token catches what the MIME token misses.
Trusting the declared type before an image library. Passing a PDF to sharp because mimetype said image/jpeg throws Error: Input buffer contains unsupported image format, usually inside a queue worker where it surfaces as a retry loop rather than a 4xx. Detect first, then dispatch; a mismatched file should be a 415 at the edge, not a poison message in your job queue.
Verification
Prove the client helper catches a rename, and that an unmapped extension really does produce an empty string:
(async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
const liar = new File([png], "invoice.pdf", { type: "application/pdf" });
const a = await inspect(liar);
console.assert(a.verdict === "mismatch", `expected mismatch, got ${a.verdict}`);
console.assert(a.sniffed === "image/png", `expected image/png, got ${a.sniffed}`);
const noExt = new File([png], "capture.qqq");
console.assert(noExt.type === "", `expected "", got "${noExt.type}"`);
console.log("inspect() verified");
})();
Then prove the server ignores the declared type:
# A real PNG header in a file named and declared as an SVG.
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\x0d' > fake.svg
curl -s -X POST -F 'file=@fake.svg;type=image/svg+xml' http://localhost:3000/upload
# {"storedAs":"image/png","ext":".png"} <- content wins, declaration ignored
# And the genuine SVG from the attack above, this time declared as a PNG.
curl -s -X POST -F 'file=@pic.svg;type=image/png' http://localhost:3000/upload
# {"error":"declared image/png but content is unrecognised"} <- HTTP 415
The two responses are the whole argument: the declared type changed nothing in either direction.
Frequently Asked Questions
Is File.type ever safe to use?
Yes, for anything cosmetic — picking an icon, deciding whether to render an inline preview, choosing a compression path. It is unsafe the moment it decides whether a file is stored, served, or executed. Treat it exactly like a filename: user input that happens to be usually correct.
Why is file.type an empty string for a file I can clearly open?
Because the machine has no registry or shared-mime-info entry for that extension. Windows without the HEIF Image Extensions package returns "" for .heic, and every browser returns "" for a Blob constructed without a type option. Fall back to application/octet-stream when you must send a header, and sniff the bytes when you need to know.
Does sniffing magic bytes in the browser make the upload safe?
No. Everything on the client is under the user’s control, and a raw HTTP client skips it entirely. Client sniffing buys you a faster error message and a smaller bill, since a 300 MB video that fails the header check never leaves the machine — but the server must repeat the check with content inspection regardless.
Can magic bytes tell a .docx from a .xlsx?
Not on their own — both are ZIP archives beginning 50 4B 03 04, as are .jar, .epub and .odt. You have to open the archive and read [Content_Types].xml, which is what libmagic and file-type do internally. Anywhere you accept archives, pair type detection with virus scanning on the extracted contents.
Should I still send a Content-Type when uploading?
Send file.type || "application/octet-stream" so proxies and object stores have something valid to record, then overwrite the stored value server-side with the detected type once the object has been inspected. Never let the client’s value become the Content-Type you serve back.