---
title: signed-url-policy
description: A fail-safe guard that enforces safe defaults on url() and signedUploadUrl() - force a download disposition, cap expiry, and cap the signed upload size.
---

The built-in `signedUrlPolicy()` plugin turns the security caveats the [`url()`](/docs/api/url) and [`signedUploadUrl()`](/docs/api/signed-upload-url) docs spell out into the **default**. It's a [wrap plugin](/docs/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()`](/docs/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).

:::warning
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](/docs/api/url): the security override wins. If you rely on permanent public URLs and don't want this, set `disposition: false`.
:::

### 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 call 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
await files.signedUploadUrl("a", {} as never); // pinned to 900 for runtime callers
```

### 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
```

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

## 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()`](/docs/plugins/validation) for direct uploads and [`contentType()`](/docs/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).
