Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Provider gaps

A register of per-adapter quirks that don't fit the capability matrix - signed-URL caps, ignored expiries, copy costs, Content-Disposition support.

files.capabilities answers the clean yes/no questions — can this adapter range-read, sign a URL, copy server-side. This page is the register for everything that doesn’t reduce to a flag: ceilings, silently-ignored options, and cost surprises that only show up against the real provider. It pairs with the capability matrix — check capabilities to branch in code, read this to understand the edges.

Signed URLs and expiry

capabilities.signedUrl.supported tells you whether url() can mint a signed or tokenized URL at all, and capabilities.signedUrl.expiry what happens to expiresIn once it can: "exact", "provider" (the provider sets the lifetime), or "none" (a permanent link). The wrinkles below are the detail behind those values.

An explicit expiresIn is never silently dropped. On an adapter that can only return permanent links (capabilities.signedUrl.supported is false), passing one throws an Unsupported error; a plain url(key) returns the permanent link. On an adapter that can sign but has a publicBaseUrl, passing one returns a signed URL instead of the CDN link. What remains are the adapters below, which return a working URL whose lifetime the provider fixes, or which only have permanent links.

  • Vercel Blob — public blobs only have the permanent CDN URL, so an expiresIn throws Unsupported. Private blobs mint a Vercel Signed URL that honors expiresIn; Vercel caps the lifetime at 7 days server-side.
  • Box — url() returns a tokenized download URL, but Box controls its TTL server-side (signedUrl.expiry is "provider"). expiresIn is accepted for API symmetry and ignored. With publicBaseUrl or publicByDefault, a plain url() returns a permanent public link instead (publicUrl is true); an explicit expiresIn still gets the tokenized link.
  • PocketBase — file-token URLs have a server-controlled TTL (short, ~minutes; signedUrl.expiry is "provider"), so expiresIn is advisory. With publicBaseUrl, url(key) returns the permanent link; an explicit expiresIn throws Unsupported.
  • Convex — serving URLs do not expire while the file exists, so an expiresIn throws Unsupported.
  • UploadThing (default public-read ACL) — url() returns the permanent public URL: signedUrl.supported is false and an expiresIn throws Unsupported. Direct uploads still work: signedUpload.supported is true, which is what the gateway presigns on.

expiresIn has a hard ceiling

  • Dropbox — temporary links always live about 4 hours: Dropbox sets the lifetime, not expiresIn (signedUrl.expiry is "provider"), so a shorter expiresIn is accepted but the link still lasts ~4h. An expiresIn above 14400s is refused in code: url() throws, and the ceiling is surfaced as capabilities.signedUrl.maxExpiresIn. Use publicByDefault: true (or publicBaseUrl) for a permanent public link instead; in those modes a plain url(key) is permanent and uncapped; an explicit expiresIn still gets a ~4h temporary link.
  • Azure — user-delegation SAS (AAD credentials) cannot exceed 7 days, the lifetime of the delegation key that signs it; url() throws above that, and the cap is surfaced as maxExpiresIn in user-delegation mode only. Account-key SAS has no such limit, so an account-key adapter leaves maxExpiresIn unset.
  • S3 and S3-compatible providers — SigV4 presigned URLs cap at 604800 seconds (7 days). Both S3 engines (the AWS SDK client and the fetch engine) and Bun’s S3 reject a longer expiresIn in url() and signedUploadUrl() before signing, with a non-retryable error, and surface the cap as maxExpiresIn. This covers every s3() wrapper and R2 over HTTP or in hybrid mode; a bare R2 binding can’t sign at all. Some S3-compatible providers enforce a shorter limit server-side, which the SDK doesn’t know about.
  • Google Cloud Storage / Firebase Storage — V4 signed URLs and POST policies cap at 7 days (604800s). @google-cloud/storage throws on a longer expiresIn in url() and signedUploadUrl(), and the cap is surfaced as maxExpiresIn.
  • UploadThing (acl: "private") — url() mints a signed URL through generateSignedURL, which throws above 7 days; the cap is surfaced as maxExpiresIn. With the default public-read ACL, url() returns the permanent public URL and no cap applies.

url() requires explicit public-mode configuration

These adapters have no signing primitive; url() throws unless you opt into a permanent public link at construction:

  • OneDrive / SharePoint — require publicByDefault: true; Graph has no signed-URL primitive. The result is an anonymous (permanent) link.
  • Google Drive — requires publicByDefault: true; returns a permanent uc?export=download link.
  • Appwrite — requires { public: true }; returns a permanent view URL. Appwrite SDKs can’t mint signed read URLs with API keys.
  • Bunny Storage — requires publicBaseUrl (a Pull Zone / CDN host); the Storage API URL needs an AccessKey header and can’t be handed out.
  • FTP / SFTP / WebDAV — require publicBaseUrl pointing at an HTTP server fronting the same tree; FTP and SFTP serve no HTTP, and a WebDAV GET needs authentication the protocol can’t sign.
  • R2 (Workers binding) — requires either publicBaseUrl or HTTP credentials (hybrid mode) for presigned URLs; a bare binding can’t sign.
  • Netlify Blobs — has no public-URL primitive at all; url() always throws. Use download().

Copy semantics

capabilities.serverSideCopy is true when copy() is a provider-side operation with no body re-transfer. When it’s false, copy() still works but routes the bytes through your process — which matters for large objects and serverless memory limits.

  • UploadThing — copy() buffers the whole object in memory (the upload API needs a Blob). A multi-GB copy will exhaust serverless memory; do it at the application layer instead.
  • FTP / SFTP — copy() buffers the whole object in memory over a single connection before re-uploading it. Size large copies accordingly.
  • Bun S3 — Bun’s S3 client has no CopyObject helper, so copy() streams source→dest through the process rather than issuing a server-side copy.
  • R2 (Workers binding) — bindings have no server-side copy; copy() streams get→put. Source and destination are not atomic.
  • Bunny Storage / Netlify Blobs — no native copy; copy() reads the source and re-writes the body at the destination. Not atomic.
  • PocketBase / Appwrite — copy() downloads the source into memory and creates a new record/file. Not atomic.
  • Cloudinary — Cloudinary has no native copy; copy() re-uploads by URL, which produces a new asset with a new asset_id and etag — not a byte-identical reference. The source must be fetchable by Cloudinary.
  • Convex — copy() is unsupported and throws: storage ids are immutable and can’t be assigned to a caller-chosen key. Download the source and upload it back, tracking the new id.

responseContentDisposition

The url() option that forces a download (and prevents stored-XSS on user-uploaded HTML/SVG) requires a signature to bind into. Adapters that can sign support it — S3 and the S3-compatible wrappers (on either client engine), Bun S3, Azure, GCS, Firebase Storage, and R2 in HTTP / hybrid mode. The rest throw rather than silently dropping the security ask: Vercel Blob, Dropbox, Box, OneDrive / SharePoint, Google Drive, UploadThing, Cloudinary, Convex, Bunny Storage, PocketBase, FTP, SFTP, WebDAV, the local fs adapter, R2 on a binding without HTTP credentials, and Netlify Blobs (whose url() always throws). Supabase and Appwrite sit in between: they can only force an attachment, so "inline" throws. Supabase takes "attachment" with or without a filename. Appwrite takes only a bare "attachment", because it maps to Appwrite’s /download endpoint (which names the file after its stored name). For buckets with untrusted content, pick a provider that can sign.

The gateway works around the refusal where it safely can. With the default downloadMode: "auto" it proxies a download it can’t redirect with attachment, and when authorize returns { disposition: "inline" } it mints URLs without a disposition. The JSON url operation can’t guarantee attachment on these adapters, so it fails unless authorize allows inline.

Uncommitted uploads

  • Azure — uncommitted blocks (from an interrupted multipart / staged-block upload) are garbage-collected by Azure after ~7 days. A resume past that window starts fresh rather than continuing.

Last updated on

Was this page helpful?