---
title: signed-url-policy
description: A fail-safe guard that enforces safe defaults on url() and signedUploadUrl() - force a download disposition, cap expiry, and require a server-enforced upload size limit. Provider-agnostic, no native dependencies, no metadata.
---

The built-in `signedUrlPolicy()` plugin turns the security caveats the [`url()`](/api/url) and [`signedUploadUrl()`](/api/signed-upload-url) docs spell out into the **default**. It's a [wrap plugin](/plugins#wrap-intercepting-operations) that rewrites the options of those two operations before the adapter signs - so it never throws of its own accord, never touches the body, and lets every other verb pass straight through. It has **no native dependencies** and works on any adapter.

```ts lineNumbers
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { signedUrlPolicy } from "files-sdk/signed-url-policy";

const files = createFiles({
  adapter: s3({ bucket: "uploads" }),
  plugins: [
    signedUrlPolicy({
      maxExpiresIn: 15 * 60, // no URL lives longer than 15 minutes
      maxUploadSize: 10 * 1024 * 1024, // every signed upload caps at 10 MiB
    }),
  ],
});

await files.url("user-upload.html"); // forced `attachment`, expires in <= 15 min
await files.signedUploadUrl("avatar.png", { expiresIn: 3600 }); // <= 15 min, <= 10 MiB
```

## What it enforces

Every option is independent. With **none** set the plugin still applies the headline default: `url()` forces an `attachment` disposition.

| Option          | Applies to                    | What it does                                                                     |
| --------------- | ----------------------------- | -------------------------------------------------------------------------------- |
| `disposition`   | `url()`                       | Force a download `Content-Disposition`. Defaults to `"attachment"`.              |
| `maxExpiresIn`  | `url()` + `signedUploadUrl()` | Clamp the URL lifetime (in seconds) down to this ceiling.                        |
| `maxUploadSize` | `signedUploadUrl()`           | Guarantee a server-enforced `maxSize` (in bytes) is always present, capped here. |

### Disposition

Without a forced disposition, the browser uses the stored Content-Type to decide whether to **render or download** a URL's contents - so a user-uploaded `.html` (or a script-bearing SVG) executes inline at your bucket's origin. That's stored XSS in your domain's trust context, and it's exactly the warning the [`url()`](/api/url) docs carry. The policy defaults `disposition` to `"attachment"` so those files download instead.

It only fills in or overrides an **unsafe** disposition. A call that already asks for an `attachment` is left untouched, so a caller's `'attachment; filename="report.pdf"'` keeps its filename; a call asking for `inline` (or passing nothing) is forced to the policy value.

```ts lineNumbers
signedUrlPolicy(); // disposition defaults to "attachment"

await files.url("user.html"); // -> attachment
await files.url("user.html", { responseContentDisposition: "inline" }); // -> attachment (overridden)
await files.url("doc.pdf", {
  responseContentDisposition: 'attachment; filename="report.pdf"',
}); // -> kept as-is (already a download)
```

Pass a full `'attachment; filename="..."'` string to set a default filename, or `disposition: false` to disable the guard entirely (you keep the expiry cap and lose the XSS protection).

<Callout type="warn">
  On signing adapters, setting a `Content-Disposition` **forces the signing
  path** even when `publicBaseUrl` is configured - a permanent CDN URL has no
  signature to bind the override into. That's the [documented, safe-by-default
  behavior](/api/url): the security override wins. If you rely on permanent
  public URLs and don't want this, set `disposition: false`.
</Callout>

### Expiry

`maxExpiresIn` caps the lifetime of both `url()` and `signedUploadUrl()`. A request for a longer TTL is clamped down; a shorter one is left as-is. To **guarantee** the ceiling, a `url()` with no `expiresIn` is pinned to the cap rather than left to the adapter's own (unknown) default - so set `maxExpiresIn` to the real ceiling you want, not higher than it.

```ts lineNumbers
signedUrlPolicy({ maxExpiresIn: 900 });

await files.url("a", { expiresIn: 86_400 }); // clamped to 900
await files.url("a", { expiresIn: 60 }); // left at 60
await files.url("a"); // pinned to 900
```

### Upload size

When omitted, `signedUploadUrl()` falls back to a presigned PUT with **no server-side size limit** - anyone with the URL can upload an arbitrarily large file until it expires. Set `maxUploadSize` and the policy guarantees a `maxSize` is always present: injected when the caller omits it, clamped when it's over the cap.

```ts lineNumbers
signedUrlPolicy({ maxUploadSize: 10 * 1024 * 1024 });

await files.signedUploadUrl("a", { expiresIn: 60 }); // maxSize injected: 10 MiB
await files.signedUploadUrl("a", { expiresIn: 60, maxSize: 50_000_000 }); // clamped to 10 MiB
await files.signedUploadUrl("a", { expiresIn: 60, maxSize: 4096 }); // left at 4 KiB
```

<Callout type="warn">
  Enforcing a `maxSize` makes supporting adapters mint a presigned **POST** form
  (with a `content-length-range` policy) instead of a PUT, and adapters whose
  direct-upload primitive can't bind a size limit **fail closed** - they throw
  rather than hand out an unbounded URL. A size policy therefore turns an
  unenforceable provider into a loud error instead of a silent hole, which is
  the point.
</Callout>

## Ordering

Put `signedUrlPolicy()` **first** (outermost) so it sees the caller's original `url()` / `signedUploadUrl()` request before anything downstream, and so the options it rewrites reach the adapter that actually signs.

```ts
plugins: [signedUrlPolicy({ maxExpiresIn: 900 }), encryption(key)];
```

## Things to keep in mind

- **It only guards the two URL-minting verbs.** `url()` and `signedUploadUrl()` are rewritten; reads, writes, `copy`, `move`, and `list` pass straight through untouched.
- **It stores no metadata and transforms nothing on disk.** A bucket behind this policy is indistinguishable from one without it - safe to enable or remove at any time.
- **It's a policy, not a scanner.** It enforces safe-by-default _headers and limits_; it doesn't inspect the bytes a signed upload will receive. Pair it with [`validation()`](/plugins/validation) for direct uploads and [`contentType()`](/plugins/content-type) if you don't trust client-declared types.
- **It changes nothing on adapters without the matching primitive.** `expiresIn` is ignored by always-public adapters (e.g. Vercel Blob); on those the disposition override and size cap surface the same way a direct call would (returning unchanged, or throwing where the adapter has no primitive).
