# Delete one tenant's files without scanning a shared Blob store

> One Blob store per tenant is worth it when delete needs a credential boundary. A pathname prefix is enough when your server owns every path.
> By Dave · 2026-09-26
> Source: https://otf-kit.dev/blog/vercel-blob-unlimited-stores-tenant-isolation

A multi-tenant app on Vercel can keep every customer's files in one Blob store under a pathname prefix, or give each customer a store. On 23 September 2026 Vercel removed the caps of 100 stores on Hobby, 500 on Pro, and 1,000 on Enterprise. One store per tenant is worth the create when export and delete must be a credential boundary. A mistaken pathname then cannot read or delete another tenant, because that credential does not open the other store. A prefix is still the right layout when your server builds every pathname from the authenticated tenant id and deletes from URLs saved on that tenant's rows.

Creating a store is one Blob Advanced Operation, the same class as `put()`, `copy()`, and `list()`. On Pro that class is $5.00 per million. On Hobby it counts toward the 2,000 advanced operations included each month. Deleting a store is free. Storage, operations, and data transfer for the same bytes cost the same in one store or in many. The create buys a unit you can hand to a job, wipe, or destroy. It does not buy a cheaper disk.

## What still bills when stores are unlimited

Storage size, simple operations, advanced operations, and data transfer still bill on use. Storage is the monthly average size. A simple operation is a cache-miss read by URL, or `head()`. An advanced operation is `put()`, `copy()`, `list()`, or a store create. Data transfer bills when a blob is downloaded. Every URL access is also an edge request, and a cache miss adds fast origin transfer. A blob larger than 512 MB is never cached, so every read of it is a miss.

`del()` is free of charge. A batched `del([urls])` still counts each blob toward the rate limit, so 100 pathnames are 100 operations against that limit, not one HTTP call. Hobby allows 1,200 simple and 900 advanced operations per minute. Pro allows 7,200 and 4,500. Dashboard browsing and uploads count as advanced operations too.

Hobby includes 1 GB of storage, the first 10,000 simple operations, the first 2,000 advanced operations, and the first 10 GB of transfer each month. Past that, Blob access stops and the overage is not billed. Extra stores do not raise the 1 GB ceiling. About 2,000 creates in a month, with no `put()` or `list()` beside them, spend the Hobby advanced allowance on empty stores.

Private and public stores share storage and operation prices. A private file is read by your server, then streamed to the caller, so both hops bill. A public URL is fetched by the client. Tenant documents belong in a private store. Access mode and region are fixed at create time. The CLI uses `iad1` when you omit `--region`.

## When a pathname prefix is enough

One store, one credential. On Vercel the SDK pairs a short-lived OIDC token with one `BLOB_STORE_ID` and refreshes that token. Outside Vercel, or for a signed browser upload, the credential is `BLOB_READ_WRITE_TOKEN`. An explicit `token` argument always beats OIDC.

![A shared store with pathname tabs is fragile when one credential opens every tenant](https://cdn.otf-kit.dev/blog/vercel-blob-unlimited-stores-tenant-isolation/inbody1-20260926a.png)

```ts
import { del, list, put } from '@vercel/blob';

export async function putTenantFile(tenantId: string, name: string, body: Blob) {
  return put(`tenants/${tenantId}/${name}`, body, {
    access: 'private',
    addRandomSuffix: true,
  });
}

export async function deleteTenantPrefix(tenantId: string) {
  const prefix = `tenants/${tenantId}/`;
  let cursor: string | undefined;

  do {
    const page = await list({ prefix, cursor, limit: 1000 });
    if (page.blobs.length > 0) {
      await del(page.blobs.map((blob) => blob.url));
    }
    cursor = page.hasMore ? page.cursor : undefined;
  } while (cursor);
}
```

`addRandomSuffix` defaults to false. Turn it on so two uploads of the same name do not collide, then delete the URL `put()` returned. Each `list()` page is one advanced operation. The default page size is 1,000. `del()` does not throw if the URL is already gone, so a bad delete can look finished. Removed objects can stay on the CDN for up to a minute. Objects your rows forgot stay until a later list sees them.

Keep the prefix when your server is the only writer, you never hand the credential to a tenant or another service, and tenants are small enough that the sweep is a few `list()` calls. A leaked server credential that already exposes the rest of the account is not fixed by a second store. A public store fails even with a perfect prefix: anyone with the URL can read the file.

## When a store per tenant is worth the create

Create a user-created store when the unit you export, revoke, or destroy is the tenant. `POST https://api.vercel.com/storage/stores/blob` takes `name` (required, at most 70 characters), `access` (`private` or `public`, default `public`), and an optional `region`. Set `access` to `private`. That POST is one advanced operation. `vercel blob create-store <name> --access private` is the same create.

The published 200 schema for that POST lists access, region, kind, size, and project metadata. It does not list an id, so do not invent `body.store.id`. Match the name you sent with `vercel blob list-stores --all`, save that id on the tenant row, and pass it as `storeId`. Leave the project's single `BLOB_STORE_ID` on the production store. Pointing it at a tenant store moves production traffic.

![One store credential is the unit you can hand out, empty, or delete](https://cdn.otf-kit.dev/blog/vercel-blob-unlimited-stores-tenant-isolation/inbody2-20260926a.png)

```ts
import { put } from '@vercel/blob';

export async function putInTenantStore(storeId: string, name: string, body: Blob) {
  return put(name, body, {
    access: 'private',
    addRandomSuffix: true,
    storeId,
  });
}

export async function deleteTenantStore(storeId: string, platformToken: string) {
  const response = await fetch(
    `https://api.vercel.com/storage/stores/blob/${encodeURIComponent(storeId)}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${platformToken}` },
    },
  );
  if (!response.ok) {
    throw new Error(`Blob store delete failed: ${response.status}`);
  }
  const body = (await response.json()) as { id: string };
  return body.id;
}
```

Pass `storeId` as `store_<id>` or `<id>`. Leave `VERCEL_OIDC_TOKEN` in the environment. Copying it into `oidcToken` skips refresh, and later calls fail with 403. A read-write `token` is the override for a job outside Vercel, and it opens only that store.

Read the tenant row before you POST. A retry opens a second store and bills another advanced operation. [An owned API stores the first success and replays it](https://otf-kit.dev/blog/idempotency-keys-owned-api). The first create is the one you keep.

The platform token that may create and delete stores can destroy every tenant store. Keep it off the customer request path. [Rotate that secret with dual-key overlap](https://otf-kit.dev/blog/secrets-rotation-owned-backend). A project-default store is a different object: private, lazy, OIDC, and the mutation APIs do not manage it. Delete only a user-created store.

Export is still `list()` with no prefix, then `get()` or the download URL. There is no export-store call. This `storeId` cannot return the next tenant. Listing a large tenant still costs one advanced operation per thousand objects. Delete can skip that list.

`vercel blob empty-store <store-id>` deletes every blob and keeps the store id. `vercel blob delete-store <store-id>` removes the store. Both take `--yes` in a script. Empty the store when the tenant stays. Delete it when the tenant is gone. Store deletion is free.

## Split production, staging, and preview

Production, staging, and shared preview are three stores. Three advanced operations, once, stop a preview write from landing on production objects. Make that split even when every production tenant shares one store.

One preview store still mixes branches. Every deployment that shares `BLOB_STORE_ID` can list the others. For a disposable branch, create a store, pass that `storeId` only into that deployment, and `DELETE /storage/stores/blob/{id}` when the branch closes. The delete is free. Bytes the preview wrote still bill until then. Do not point preview at the production store id to skip the create.

## When you don't need this

Skip a store per tenant for a single customer, or for public assets. Skip it when every blob URL already sits on the tenant row: offboarding is `del()` of that list, and production still stays off the preview store. Skip it on Hobby if signups would create thousands of stores. About 2,000 creates consume the monthly advanced allowance. Stay on one private store and a server-built prefix until the plan can absorb them.

Sweep a prefix as a paced job, not a tight loop. [Refuse that burst before it melts the rate limit](https://otf-kit.dev/blog/queue-backpressure-owned-backend). Each `list()` page is an advanced operation, and each URL in a batched `del()` counts toward the rate limit.

OTF kits do not ship a per-tenant Blob layout yet. The owned upload path starts from the templates at https://otf-kit.dev/templates.

## Sources

- https://vercel.com/changelog/unlimited-vercel-blob-stores-on-every-plan
- https://vercel.com/docs/vercel-blob/usage-and-pricing
- https://vercel.com/docs/vercel-blob/using-blob-sdk
- https://vercel.com/docs/rest-api/storage/create-a-blob-store
- https://vercel.com/docs/rest-api/storage/delete-a-blob-store
- https://vercel.com/docs/cli/blob
- https://vercel.com/kb/guide/vercel-blob
