Skip to content
Files SDK
Esc
navigateopen⌘Jpreview
On this page

signed-url-policy

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() and signedUploadUrl() docs spell out into the default. It’s a wrap plugin 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.

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

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).

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.

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.

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

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.

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() for direct uploads and contentType() 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).

Was this page helpful?