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.
expiresIn on a permanent link, or a provider-set lifetime
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
expiresInthrowsUnsupported. Private blobs mint a Vercel Signed URL that honorsexpiresIn; Vercel caps the lifetime at 7 days server-side. - Box —
url()returns a tokenized download URL, but Box controls its TTL server-side (signedUrl.expiryis"provider").expiresInis accepted for API symmetry and ignored. WithpublicBaseUrlorpublicByDefault, a plainurl()returns a permanent public link instead (publicUrlistrue); an explicitexpiresInstill gets the tokenized link. - PocketBase — file-token URLs have a server-controlled TTL (short, ~minutes;
signedUrl.expiryis"provider"), soexpiresInis advisory. WithpublicBaseUrl,url(key)returns the permanent link; an explicitexpiresInthrowsUnsupported. - Convex — serving URLs do not expire while the file exists, so an
expiresInthrowsUnsupported. - UploadThing (default
public-readACL) —url()returns the permanent public URL:signedUrl.supportedisfalseand anexpiresInthrowsUnsupported. Direct uploads still work:signedUpload.supportedistrue, 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.expiryis"provider"), so a shorterexpiresInis accepted but the link still lasts ~4h. AnexpiresInabove 14400s is refused in code:url()throws, and the ceiling is surfaced ascapabilities.signedUrl.maxExpiresIn. UsepublicByDefault: true(orpublicBaseUrl) for a permanent public link instead; in those modes a plainurl(key)is permanent and uncapped; an explicitexpiresInstill 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 asmaxExpiresInin user-delegation mode only. Account-key SAS has no such limit, so an account-key adapter leavesmaxExpiresInunset. - S3 and S3-compatible providers — SigV4 presigned URLs cap at 604800 seconds (7 days). Both S3 engines (the AWS SDK client and the
fetchengine) and Bun’s S3 reject a longerexpiresIninurl()andsignedUploadUrl()before signing, with a non-retryable error, and surface the cap asmaxExpiresIn. This covers everys3()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/storagethrows on a longerexpiresIninurl()andsignedUploadUrl(), and the cap is surfaced asmaxExpiresIn. - UploadThing (
acl: "private") —url()mints a signed URL throughgenerateSignedURL, which throws above 7 days; the cap is surfaced asmaxExpiresIn. With the defaultpublic-readACL,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 permanentuc?export=downloadlink. - 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 anAccessKeyheader and can’t be handed out. - FTP / SFTP / WebDAV — require
publicBaseUrlpointing at an HTTP server fronting the same tree; FTP and SFTP serve no HTTP, and a WebDAVGETneeds authentication the protocol can’t sign. - R2 (Workers binding) — requires either
publicBaseUrlor 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. Usedownload().
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 aBlob). 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
CopyObjecthelper, socopy()streams source→dest through the process rather than issuing a server-side copy. - R2 (Workers binding) — bindings have no server-side copy;
copy()streamsget→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 newasset_idandetag— 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.