---
title: validation
description: A fail-closed guard that vets every upload before any bytes move - enforce a max/min size, an allowed-MIME-type list, and a key-naming rule.
related:
  - /guides/presigned-upload-validation
---

The built-in `validation()` plugin is a **fail-closed guard**: it checks each write against the rules you set and rejects a bad one by throwing, so no bytes ever reach the adapter. It's the simplest kind of [wrap plugin](/docs/plugins#wrap-intercepting-operations) - it vetoes rather than transforms.

Unlike [`compression()`](/docs/plugins/compression) and [`encryption()`](/docs/plugins/encryption), it never touches the body or writes any metadata, so there's nothing to undo on the way back out. It has **no native dependencies** and works on any adapter.

```ts lineNumbers
import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { validation } from "files-sdk/validation";

const files = createFiles({
  adapter: r2({ bucket: "uploads" }),
  plugins: [
    validation({
      maxSize: 10 * 1024 * 1024, // 10 MiB
      allowedTypes: ["image/*", "application/pdf"],
      key: /^[\w.-]+$/,
    }),
  ],
});

await files.upload("photo.png", bytes); // ok
await files.upload("notes.txt", "..."); // throws: type not allowed
```

## What it checks

Every option is independent - set any combination, and with none set the plugin is a no-op pass-through.

| Option | What it does |
| --- | --- |
| `maxSize` | Reject uploads larger than this many bytes. |
| `minSize` | Reject uploads smaller than this many bytes - e.g. `1` to refuse empty files. |
| `allowedTypes` | Reject uploads whose MIME type isn't in the list. |
| `key` | Reject `upload` (and `copy` / `move` destinations) whose key fails the rule. |

### Sizes

`maxSize` and `minSize` are byte counts. Known-length bodies - strings (measured in UTF-8 bytes), `Uint8Array`, `ArrayBuffer`, `Blob`, and `File` - are measured without being read or copied and forwarded as-is, so a multi-gigabyte `File` over `maxSize` is rejected without being loaded into memory. Only an unknown-length stream has to be buffered to measure it (the same trade-off the [buffering plugins](/docs/plugins/compression) make).

### Types

Each `allowedTypes` entry is an exact type (`"image/png"`) or a group wildcard (`"image/*"`). Matching is case-insensitive and ignores any `; charset=...` parameter. The type checked is `options.contentType` if you pass it, otherwise a `Blob`/`File`'s own `.type`, otherwise the type inferred from the key's extension - and the plugin forwards that checked type as the upload's `contentType`, so it's the type the object is stored as.

A type that isn't exactly one well-formed media type - `image/png, text/html`, a missing subtype, a control character - fails the `type` rule outright. A browser reads a comma-separated list as its _last_ entry, so `image/png;a=b, text/html` would otherwise pass an `image/*` allowlist and render as HTML.

```ts lineNumbers
validation({ allowedTypes: ["image/*"] });

await files.upload("photo.png", bytes); // ok - inferred image/png
await files.upload("doc.pdf", bytes); // throws - application/pdf not allowed
```

### Keys

`key` is either a `RegExp` the key must match or a predicate that returns `true` for keys you allow. Anchor your pattern (`/^[\w.-]+$/`) and don't use the `g` flag. The rule also guards the **destination** of `copy` and `move`, so a rename can't smuggle in a key your uploads would reject.

```ts lineNumbers
validation({ key: (key) => key.startsWith(`${tenantId}/`) });
```

## Telling failures apart

Every rejection is a `ValidationError` - a regular `FilesError` (`code: "Invalid"`, so it's never retried) with a `reason` discriminant of `"size"`, `"type"`, or `"key"` - so you can branch on which rule failed without parsing the message:

```ts lineNumbers
import { ValidationError } from "files-sdk/validation";

try {
  await files.upload(key, body);
} catch (error) {
  if (error instanceof ValidationError && error.reason === "type") {
    return reply(415, "unsupported file type");
  }
  if (error instanceof ValidationError && error.reason === "size") {
    return reply(413, "file too large or too small");
  }
  throw error;
}
```

`maxSize` and `minSize` share `reason: "size"` - the message says which bound was crossed. The `signedUploadUrl()` fail-closed throw (below) is a plain `FilesError`, **not** a `ValidationError`: that's the plugin refusing an unenforceable operation, not your file failing a rule.

## Ordering

Plugins run in array order, `plugins[0]` outermost. Two neighbours need to sit in a particular place relative to `validation()`:

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

- **[`contentType()`](/docs/plugins/content-type) goes before it.** `validation()` checks the type the client _claims_. A `contentType()` placed after it can still relabel the upload from its bytes - a `.png` that's really HTML becomes `text/html` - after `allowedTypes` has already approved `image/png`. With `contentType()` first, `validation()` checks the corrected type and rejects it.
- **[`versioning()`](/docs/plugins/versioning), [`softDelete()`](/docs/plugins/soft-delete), [`dedup()`](/docs/plugins/dedup), and body transforms go after it.** Those plugins' own housekeeping writes (`.versions/...`, `.trash/...`, `.dedup/...`, and dedup's empty pointer bodies) only pass through the plugins after them. Put `validation()` after them and a rule like `key: /^[\w.-]+$/` or `minSize: 1` rejects those internal writes, so every overwrite, delete, or upload fails. In front of them, it vets only the caller's own keys and bytes.

## Things to keep in mind

:::warning
`signedUploadUrl()` hands upload capability to a client that writes **directly** to the store, bypassing the plugin. When a size or type rule is set, minting one would be a silent hole - so `signedUploadUrl()` **fails closed** and throws. (A key-only policy still mints the URL, after checking the requested key.)
:::

- **Reads and `url()` pass straight through; `copy` and `move` only have their destination key checked.** The plugin only guards writes; it transforms nothing, so there's nothing to reverse on download.
- **It stores no metadata.** Nothing rides along on the object, so a validated bucket is indistinguishable from an unvalidated one - safe to enable or remove at any time.
- **Size rules buffer unknown-length streams.** Measuring a stream means draining it, which is incompatible with [resumable uploads](/docs/resumable). Key and type rules never touch the body, so a key/type-only policy stays fully streaming.
- **It's a guard, not a sanitizer.** It vets the _declared_ type and the key; it doesn't sniff magic bytes. Pair it with [`contentType()`](/docs/plugins/content-type), placed **before** it, if you don't trust the client-declared type.
