---
title: content-type
description: Decide each upload's Content-Type from its bytes, not the client's claim - magic-byte sniffing stops mislabeled HTML or SVG being stored as an image.
---

The built-in `contentType()` plugin sets an upload's `Content-Type` from what the bytes **actually are**, not what the client said they were. On `upload` it magic-byte-sniffs the body and either corrects the stored type to match (the default) or rejects a mismatch — so a `.png` whose bytes are really HTML or SVG can't be stored under an image type and later served inline. It's the [wrap plugin](/docs/plugins#wrap-intercepting-operations) counterpart to [`validation()`](/docs/plugins/validation): validation vets the _declared_ type, `contentType()` verifies the _real_ one.

```ts lineNumbers
import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { contentType } from "files-sdk/content-type";

const files = createFiles({
  adapter: r2({ bucket: "uploads" }),
  plugins: [contentType({ onMismatch: "reject" })],
});

await files.upload("avatar.png", pngBytes); // ok — bytes are a PNG
await files.upload("avatar.png", htmlBytes); // throws — bytes are HTML
```

## What it recognizes

The sniffer is deliberately scoped to where a verdict is unambiguous and useful:

- **Images** — PNG, JPEG, GIF, BMP, WebP, TIFF, and ICO, by their magic bytes.
- **PDF** — the `%PDF` signature.
- **HTML, SVG, and XML** — the security-relevant part. These are text, so they have no fixed magic bytes; a leading text scan (skipping a BOM, whitespace, and any leading `<!-- … -->` comments) catches `<!doctype html>`, `<html>`, `<script>`, `<svg>`, `<?xml>`, and friends. A comment with nothing recognizable after it counts as HTML, unless the declared type is XML-based. A document behind an `<?xml` prolog is classified by its root element: an `svg` root (or one in the SVG namespace) is `image/svg+xml`, an `html` root (or one in the XHTML namespace) is `application/xhtml+xml`, and anything else is `application/xml`.

Container formats whose magic bytes are shared by many real types — ZIP (which also backs `.docx`, `.jar`, `.epub`), gzip, and the `ftyp` audio/video family — are intentionally **not** sniffed: relabeling them would do more harm than good. Bodies it can't identify are handled by [`onUnknown`](#onunknown).

You can run the same detection yourself with the exported `detectContentType(bytes)`, which returns the sniffed MIME type or `undefined`.

## Options

| Option | Values | What it does |
| --- | --- | --- |
| `onMismatch` | `"correct"` (default), `"reject"` | What to do when the sniffed type contradicts the declared one. |
| `onUnknown` | `"trust"` (default), `"reject"` | What to do when the bytes match no known signature. |

### onMismatch

When the bytes disagree with the type the caller declared (or that the key's extension implies):

- **`"correct"`** (default) — overwrite the stored `Content-Type` with the sniffed one, so the object is always stored as what it actually _is_. The mislabeled file still lands, but under its true type.
- **`"reject"`** — throw, so the upload never reaches the adapter. The security-hardening choice: a `.png` whose bytes are HTML is refused outright.

A declared `application/octet-stream` (i.e. no real claim) is treated as _unset_ rather than a contradiction — the sniffed type fills it in under both modes. And when the declared type already agrees with the bytes, it's stored as declared (its type and subtype lowercased), so a `text/html; charset=utf-8` keeps its `charset` parameter — including a type implied only by the key's extension, so a `photo.png` of real PNG bytes is stored as `image/png` rather than the adapter's default.

A declared type that isn't exactly one well-formed media type — `image/png, text/html`, a missing subtype, a control character — is refused with an `Invalid` error in every mode, whatever the bytes are. A browser reads a comma-separated list as its _last_ entry, so storing one would render a type the sniff never compared.

Agreement allows for more than one spelling of the same thing: `image/vnd.microsoft.icon` (the registered `.ico` type) agrees with the sniffed `image/x-icon`, and since a generic XML root only proves "some XML", an XML-based type — `application/rss+xml`, `application/gpx+xml`, `text/xml`, … — agrees with a sniffed `application/xml`. Two exceptions keep active content from hiding behind that leniency: an SVG or XHTML root needs exactly `image/svg+xml` or `application/xhtml+xml`, and an XML document never agrees with an `image/`, `audio/`, `video/`, or `font/` type other than `image/svg+xml` (so `image/x+xml` can't slip an XML document past an `image/*` allowlist).

:::note
Any XML document can carry script in a nested XHTML-namespaced element, which runs when a browser renders it. Allowing an XML type at all (`application/rss+xml`, `text/xml`, …) accepts that; serve such uploads with an `attachment` disposition.
:::

:::warning
`onMismatch: "correct"` makes the stored type _honest_; it does not make a dangerous file safe. Detecting that an upload is really HTML and storing it as `text/html` still leaves it executable if you serve it inline. To **block** mislabeled active content, use `onMismatch: "reject"`; to serve user uploads safely, pair this with a forced `attachment` disposition on [`url()`](/docs/api/url).
:::

### onUnknown

When the bytes match no known signature:

- **`"trust"`** (default) — keep the declared type. Sniffing only overrides types it's sure about, so a legitimate `.csv`, `.docx`, or arbitrary binary keeps its declared type untouched. An explicit `contentType` or a `Blob`'s own `.type` is stored as-is; with neither, the adapter's default applies — a type implied only by the key's extension is never stored on its own say-so.
- **`"reject"`** — throw. A strict allowlist-by-signature posture: nothing lands unless its bytes are recognized.

## Ordering

Plugins run in array order, `plugins[0]` outermost. Put `contentType()` **first** — before [`validation()`](/docs/plugins/validation) and before any body-transforming plugin — so it sniffs the caller's original bytes rather than compressed or encrypted ones, and every plugin after it sees the corrected type:

```ts
plugins: [
  contentType(),
  validation({ allowedTypes }),
  compression(),
  encryption(key),
];
```

:::warning
The reverse order, `[validation(), contentType()]`, lets mislabeled uploads through. `validation()` approves the type the client _claims_ (say `image/png`, from `avatar.png`), then `contentType()` relabels the upload from its bytes — to `text/html`, after the allowlist has already passed it. With `contentType()` first, `validation()` checks the corrected type and rejects it.
:::

## Things to keep in mind

- **It never buffers the whole body.** Only the first 512 bytes matter, so known-length bodies (strings, `Uint8Array`, `Blob`, `ArrayBuffer`) are peeked in place with no copy, and **streams stay streaming** — the prefix is read and replayed, the rest flows straight through. This is the one body-touching plugin that doesn't disable [streaming uploads](/docs/api/upload).
- **It writes no metadata.** The verdict lands in the object's own `Content-Type`; nothing rides along in `metadata`, so a sniffed bucket is indistinguishable from an un-sniffed one — safe to enable or remove at any time.
- **Reads, `url()`, `copy`, and `move` pass straight through.** The plugin only acts on `upload`; it stores the right type up front, so there's nothing to undo on the way back out.
- **`signedUploadUrl()` fails closed.** A presigned upload writes directly to the store, bypassing the sniff, so minting one would be a silent hole — it throws. Upload through the `Files` instance to enforce sniffing.
- **It's a sniffer, not a validator.** It decides the stored type; it doesn't enforce a size limit or an allowed-type list. Pair it with [`validation()`](/docs/plugins/validation), placed **after** it, when you want both.
