---
title: Vercel Blob
description: Store files in Vercel Blob with files-sdk, using OIDC on Vercel (BLOB_STORE_ID, no secret) or a BLOB_READ_WRITE_TOKEN anywhere, public or private.
related:
  - /guides/vercel-blob-authentication
  - /guides/vercel-blob-private-downloads
  - /guides/vercel-large-file-upload
---

## Installation

`@vercel/blob` is an optional peer dependency of `files-sdk` - install alongside the SDK so the adapter's imports resolve at runtime.

```package-install
files-sdk @vercel/blob
```

## Usage

```ts lineNumbers
import { Files } from "files-sdk";
import { vercelBlob } from "files-sdk/vercel-blob";

// On Vercel, the connected Blob store authenticates through OIDC. Anywhere
// else, set BLOB_READ_WRITE_TOKEN. No options needed for either.
const files = new Files({ adapter: vercelBlob() });

await files.upload("avatars/abc.png", file);
const url = await files.url("avatars/abc.png");
```

## Authentication

Vercel Blob takes one of two credentials, and the adapter picks whichever it finds without any options:

| Credential | Comes from | Use it for |
| --- | --- | --- |
| **OIDC** (recommended) | `BLOB_STORE_ID`, plus a short-lived token: per request on Vercel Functions, `VERCEL_OIDC_TOKEN` in builds and local development | Code running on Vercel |
| **Read-write token** | `BLOB_READ_WRITE_TOKEN` (`vercel_blob_rw_…`) | Scripts, CI, and servers outside Vercel |

### OIDC on Vercel

OIDC needs no secret in your code or your environment variables. Connect the Blob store to the project in the Vercel dashboard, and the deployment gets `BLOB_STORE_ID` plus an OIDC token that Vercel rotates on its own. On Vercel Functions the token arrives with each request, in the `x-vercel-oidc-token` header, rather than as an environment variable. The adapter hands just the store id to `@vercel/blob`, which reads the current token from the request on every call, so `vercelBlob()` works the same at module scope as inside a handler. That holds when the project also has `BLOB_READ_WRITE_TOKEN`: the request's OIDC token is used first.

Because the token is short-lived, a leaked one stops working within hours, where a leaked read-write token works until you rotate it by hand.

### Read-write token

A read-write token grants full access to one store and doesn't expire. Find it on the store's page in the Vercel dashboard (Storage, then your Blob store), or run `vercel env pull` in a linked project to write it to `.env.local`. Set it as `BLOB_READ_WRITE_TOKEN`, or pass it as `token`:

```ts lineNumbers
const files = new Files({
  adapter: vercelBlob({ token: process.env.BLOB_READ_WRITE_TOKEN }),
});
```

Keep it on the server. To let a browser upload, hand it a [signed upload URL](#signed-urls) or put the [gateway](/docs/ui/server/gateway) in front of the adapter, and never ship the token itself.

### Local development

`vercel env pull` writes `VERCEL_OIDC_TOKEN` and `BLOB_STORE_ID` to `.env.local`, so OIDC works locally too. The local token expires after 12 hours. `@vercel/blob` 2.5 and later refresh it automatically in a project linked with `vercel link`; on 2.4, run `vercel env pull` again. A read-write token in `.env.local` works as well and never expires.

If your framework doesn't load `.env.local` into `process.env` (Vite, for example), pass the values explicitly:

```ts lineNumbers
const files = new Files({
  adapter: vercelBlob({
    oidcToken: import.meta.env.VERCEL_OIDC_TOKEN,
    storeId: import.meta.env.BLOB_STORE_ID,
  }),
});
```

An explicit `oidcToken` is used as given, so it isn't refreshed.

### Which credential wins

The adapter follows `@vercel/blob`'s own order, checked on every call:

1. A `token` option.
2. An `oidcToken` option, with `storeId` (option or `BLOB_STORE_ID`).
3. An OIDC token `@vercel/blob` finds itself, with `storeId` (option or `BLOB_STORE_ID`): the request's `x-vercel-oidc-token` header first, then `VERCEL_OIDC_TOKEN`.
4. `BLOB_READ_WRITE_TOKEN`.

With `BLOB_STORE_ID` and `BLOB_READ_WRITE_TOKEN` both set, a Vercel Function authenticates with the request's OIDC token, and the read-write token is used only where no OIDC token turns up, such as a script or a server outside Vercel. Without a store id there is no OIDC, so the read-write token is used even inside a request.

### Credential errors

- `vercelBlob adapter: missing credentials` means nothing could authenticate. The adapter throws it when created with none of `token`, `oidcToken`, `BLOB_READ_WRITE_TOKEN`, or `BLOB_STORE_ID` available. With only a store id, it can't know yet whether an OIDC token will arrive, so the first operation that finds none throws it instead (`@vercel/blob`'s own "No blob credentials found" error is its `cause`). That's typically code running outside Vercel, or outside a request on Vercel, for example at import time. Off Vercel, set `BLOB_READ_WRITE_TOKEN` or run `vercel env pull`. On Vercel, check that the Blob store is connected to the project and environment you deployed.
- `` `oidcToken` was passed but no `storeId` was found `` means OIDC was requested explicitly without a store id. Pass `storeId` or set `BLOB_STORE_ID`.
- A `401` or `Unauthorized` error with OIDC locally usually means the local token expired. Run `vercel env pull` again, or upgrade to `@vercel/blob` 2.5 or later. A token passed as `oidcToken` is never refreshed.

## Public and private blobs

`access` selects public or private blobs and is fixed at construction. Defaults to `"public"`. With `access: "private"`, uploads use Vercel's private mode and reads route through `blob.get()` with whichever credentials the adapter resolved (OIDC or read-write token) instead of a public URL fetch. There is no permanent public URL for private blobs, so `url()` mints a [Vercel Signed URL](https://vercel.com/docs/vercel-blob/vercel-signed-urls) instead - a presigned GET scoped to that key that expires after `expiresIn` seconds (1 hour by default, `defaultUrlExpiresIn` changes the default). Need both? Use two adapters.

`downloadTimeoutMs` bounds the body reads issued by `download()` (the public-URL fetch, or `blob.get()` in private mode). Defaults to 5 minutes; pass `0` to disable. A hung CDN response would otherwise leak a fetch that never resolves.

## Signed URLs

Vercel Signed URLs (`@vercel/blob` 2.4.0+) back two methods:

- **`url()` in private mode** issues a signed token scoped to the key and the `get` operation, expiring with the URL, then signs the URL locally. One control-API round trip per call. Vercel caps the lifetime at 7 days server-side.
- **`signedUploadUrl()`** in either mode returns a presigned `PUT`. `contentType` and `maxSize` are enforced by Vercel's CDN, so they are real constraints, not advisory headers. There is no minimum-size counterpart: a positive `minSize` throws `Unsupported` rather than handing out a URL that accepts the empty upload you asked to reject. The adapter's `addRandomSuffix` and `allowOverwrite` settings apply to the upload.

```ts lineNumbers
const files = new Files({ adapter: vercelBlob({ access: "private" }) });

// Presigned GET, valid for five minutes.
const url = await files.url("invoices/2026-q1.pdf", { expiresIn: 300 });

// Presigned PUT the browser can use directly; Vercel rejects other
// content types and anything over 10 MB at the CDN.
const upload = await files.signedUploadUrl("uploads/photo.png", {
  contentType: "image/png",
  expiresIn: 600,
  maxSize: 10 * 1024 * 1024,
});
// → { method: "PUT", url, headers: { "Content-Type": "image/png" } }
```

Public blobs keep returning their permanent CDN URL from `url()` - a presigned copy would add a round trip without restricting anything - so `capabilities.signedUrl.supported` is `true` only in private mode. In public mode, `url(key, { expiresIn })` throws `Unsupported` rather than hand back a link that never expires; omit `expiresIn` for the permanent URL. Neither the CDN URL nor a presigned URL can carry a `Content-Disposition` override, so `responseContentDisposition` throws `Unsupported` in both modes.

Signed uploads don't depend on the mode: `capabilities.signedUpload` reports `supported`, `maxSize`, and `contentType` as `true` for public and private adapters alike. The [gateway](/docs/ui/server/gateway) presigns on that, so browser uploads go straight to Vercel Blob with a presigned `PUT` in either mode, with your `maxUploadSize` and the file's content type enforced at the CDN, rather than streaming through your server.

## Options

<AutoTypeTable
  path="../../packages/files-sdk/src/vercel-blob/index.ts"
  name="VercelBlobAdapterOptions"
/>

## Limitations

User `metadata` isn't supported by the underlying API, so passing a non-empty `metadata` throws `Unsupported` rather than silently dropping it. `cacheControl` is supported through its `max-age` directive, which maps to the blob's `cacheControlMaxAge`; Vercel writes the rest of the header itself. A value with no `max-age` (e.g. `"no-store"`) throws `Unsupported` rather than being dropped.

With `allowOverwrite: false`, uploading to an existing key throws a `Conflict`.

## Compatibility

### Public access

| Method | Status | Notes |
| --- | :-: | --- |
| `upload` | ✅ |  |
| `download` | ✅ | The body is fetched from the blob URL with a `v=<etag>` query parameter taken from a fresh `head()`, so a read right after an overwrite gets the new bytes rather than the CDN's cached copy (the CDN caches each version once). A 401/403 on the body fetch is `Unauthorized`, and a 409/412 `Conflict`. |
| `delete` | ✅ |  |
| `list` | ⚠️ | Listed items' `contentType` is always `application/octet-stream` (the list API returns no content type); use `head()` for the real type. |
| `search` | ✅ |  |
| `head` | ✅ |  |
| `exists` | ✅ |  |
| `copy` | ✅ | Server-side `blob.copy`. The source's content type and cache max-age are read with `head()` and passed on, since `blob.copy` would otherwise give the copy a type guessed from its extension and the default one-month cache. |
| `url` | ⚠️ | Returns the permanent CDN URL. `expiresIn` throws `Unsupported` (the URL can't expire); `responseContentDisposition` throws (no Content-Disposition override available). Use a different provider for buckets with untrusted user-uploaded content. |
| `signedUploadUrl` | ✅ | Presigned `PUT` via Vercel Signed URLs. `contentType` and `maxSize` are enforced at the CDN; a positive `minSize` throws. |

### Private access

| Method | Status | Notes |
| --- | :-: | --- |
| `upload` | ✅ |  |
| `download` | ⚠️ | Reads go through `blob.get()` with `useCache: false`, straight from origin storage, so a read right after an overwrite gets the new bytes. `blob.get()` has no range primitive, so `range` throws. A 401/403 on the read is `Unauthorized`, and a 409/412 `Conflict`. |
| `delete` | ✅ |  |
| `list` | ⚠️ | Listed items' `contentType` is always `application/octet-stream` (the list API returns no content type); use `head()` for the real type. |
| `search` | ✅ |  |
| `head` | ✅ |  |
| `exists` | ✅ |  |
| `copy` | ✅ | Server-side `blob.copy`. The source's content type and cache max-age are read with `head()` and passed on, since `blob.copy` would otherwise give the copy a type guessed from its extension and the default one-month cache. |
| `url` | ⚠️ | Presigned `GET` via Vercel Signed URLs, honoring `expiresIn` (1 hour default, 7-day server-side cap). `responseContentDisposition` throws (no Content-Disposition override available). |
| `signedUploadUrl` | ✅ | Presigned `PUT` via Vercel Signed URLs. `contentType` and `maxSize` are enforced at the CDN; a positive `minSize` throws. |
