---
title: Provider gaps
description: 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`](/docs/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 `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](/docs/ui/server/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](/docs/ui/server/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.
