---
title: Provider gaps
description: A register of per-adapter runtime quirks that don't fit the capability matrix — signed-URL caps, ignored expiries, copy costs, and Content-Disposition support across every provider.
---

[`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. The wrinkles below are about what happens to `expiresIn` once it can.

### `expiresIn` is silently ignored

Some adapters return a working URL but don't honor the requested lifetime — the URL is either permanent or has a provider-fixed TTL.

- **Vercel Blob** — public blobs return the permanent CDN URL; `expiresIn` is ignored (there is no signing primitive). Private blobs have no public URL at all, so `url()` throws — use `download()`.
- **Box** — `url()` returns a tokenized download URL, but Box controls its TTL server-side. `expiresIn` is accepted for API symmetry and ignored.
- **PocketBase** — file-token URLs have a server-controlled TTL (short, ~minutes). `expiresIn` is ignored.
- **Convex** — serving URLs do not expire while the file exists. `expiresIn` is ignored.

### `expiresIn` has a hard ceiling

- **Dropbox** — temporary links cap at **4 hours** (14400s), enforced in code: `url()` throws above that. Use `publicByDefault: true` for a permanent shared link instead. This ceiling is surfaced as `capabilities.signedUrl.maxExpiresIn`.
- **Azure** — user-delegation SAS (AAD credentials) cannot exceed **7 days** from `startsOn`; the adapter clamps the delegation key to that window. Account-key SAS has **no** such limit, so the cap is config-dependent and is _not_ surfaced as `maxExpiresIn` — it would be wrong in shared-key mode.
- **S3 and S3-compatible providers** — SigV4 presigned URLs cap at **604800 seconds** (7 days). This is an AWS infra limit rejected at request time, not enforced by the SDK, so it's not surfaced as `maxExpiresIn`. S3-compatible providers (MinIO, Wasabi, and the other `s3()` wrappers) may enforce a different limit or none.

### `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** — require `publicBaseUrl` pointing at an HTTP server fronting the same tree; the protocols serve no HTTP.
- **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.
- **SFTP** — `copy()` buffers the whole object over a single connection. **FTP** streams instead but still round-trips the bytes through the client.
- **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 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, **Azure**, **GCS**, **Firebase Storage**, **Supabase**, 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, and SFTP. For buckets with untrusted content, pick a provider that can sign.

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