Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9edcf1e
[blob] add private blobs
Jan 9, 2026
b586538
browser
Jan 9, 2026
371428d
changeset
vvo Jan 19, 2026
90221ce
bypass
Jan 20, 2026
10388e6
Merge branch 'blob/private' of github.com:vercel/storage into blob/pr…
Jan 20, 2026
316f8db
latest
Jan 20, 2026
24f651a
main
Jan 20, 2026
39df1d8
remove live tests
Jan 21, 2026
42292f5
multi part tests
Jan 22, 2026
06d6754
add get by blob
Jan 22, 2026
dd0ec30
Merge branch 'main' into blob/private
vvo Jan 23, 2026
5cd4328
Merge branch 'main' into blob/private
shawnfeldman Jan 23, 2026
ef88d04
update blob with streams
Jan 23, 2026
6d370cf
Merge branch 'blob/private' of github.com:vercel/storage into blob/pr…
Jan 23, 2026
26d5c3c
Merge branch 'main' into blob/private
shawnfeldman Jan 23, 2026
4e6994b
add access type
Jan 23, 2026
704ff70
Merge branch 'main' into blob/private
shawnfeldman Jan 23, 2026
8e433d3
allow download urls and simplify typing
vvo Jan 27, 2026
d757a85
update
vvo Jan 27, 2026
f0898fb
update
vvo Jan 27, 2026
de430be
feat(blob): add raw headers to get() response
vvo Jan 28, 2026
062a059
feat(blob): add optional headers option to get()
vvo Jan 28, 2026
a1357e8
ensure createFolder requires access property now, it's a write like put
vvo Jan 29, 2026
4adc7b7
[blob] feat: add useCache option to bypass CDN cache (#954)
vvo Jan 29, 2026
40d47d1
add tests for private, fix method
Jan 29, 2026
f8529bc
Merge branch 'main' into blob/private
shawnfeldman Jan 29, 2026
6ddcf75
update
vvo Jan 30, 2026
a751429
Merge branch 'blob/private' of github.com:vercel/storage into blob/pr…
vvo Jan 30, 2026
73e660a
update
vvo Jan 30, 2026
9b3331d
Merge branch 'main' into blob/private
vvo Feb 4, 2026
3eb97af
fix(blob): add etag to get() response
vvo Feb 4, 2026
5203622
update
vvo Feb 4, 2026
f6bb7b4
Merge branch 'main' into blob/private
vvo Feb 4, 2026
160e9fb
update
vvo Feb 4, 2026
8879b71
Merge branch 'blob/private' of github.com:vercel/storage into blob/pr…
vvo Feb 4, 2026
512846a
Merge branch 'main' into blob/private
vvo Feb 6, 2026
5117b1c
update ts docs
vvo Feb 17, 2026
cbdb715
conditional gets
vvo Feb 17, 2026
45afd02
Merge branch 'blob/private' of github.com:vercel/storage into blob/pr…
vvo Feb 17, 2026
9e312ca
add conditional reads
vvo Feb 17, 2026
cc8f7be
changeset
vvo Feb 17, 2026
e0b0fda
update
vvo Feb 17, 2026
ec6cc72
Merge branch 'main' into blob/private
vvo Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/all-parts-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@vercel/blob": minor
---

Add private storage support (beta), a new `get()` method, and conditional gets

**Private storage (beta)**

You can now upload and read private blobs by setting `access: 'private'` on `put()` and `get()`. Private blobs require authentication to access — they are not publicly accessible via their URL.

**New `get()` method**

Fetch blob content by URL or pathname. Returns a `ReadableStream` along with blob metadata (url, pathname, contentType, size, etag, etc.).

**Conditional gets with `ifNoneMatch`**

Pass an `ifNoneMatch` option to `get()` with a previously received ETag. When the blob hasn't changed, the response returns `statusCode: 304` with `stream: null`, avoiding unnecessary re-downloads.

**Example**

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

// Upload a private blob
const blob = await put('user123/avatar.png', file, { access: 'private' });

// Read it back
const response = await get(blob.pathname, { access: 'private' });
// response.stream — ReadableStream of the blob content
// response.blob — metadata (url, pathname, contentType, size, etag, ...)

// Conditional get — skip download if unchanged
const cached = await get(blob.pathname, {
access: 'private',
ifNoneMatch: response.blob.etag,
});
if (cached.statusCode === 304) {
// Blob hasn't changed, reuse previous data
}
```

Learn more: https://vercel.com/docs/vercel-blob/private-storage
79 changes: 79 additions & 0 deletions packages/blob/src/client.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,69 @@ describe('client', () => {
'x-api-blob-request-attempt': '0',
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
'x-api-version': '12',
'x-vercel-blob-access': 'public',
},
method: 'PUT',
},
);
});

it('should upload a file with private access', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest
.fn()
.mockResolvedValueOnce({
status: 200,
ok: true,
json: () =>
Promise.resolve({
type: 'blob.generate-client-token',
clientToken: 'vercel_blob_client_fake_123',
}),
})
.mockResolvedValueOnce({
status: 200,
ok: true,
json: () =>
Promise.resolve({
url: `https://storeId.public.blob.vercel-storage.com/superfoo.txt`,
downloadUrl: `https://storeId.public.blob.vercel-storage.com/superfoo.txt?download=1`,
pathname: 'foo.txt',
contentType: 'text/plain',
contentDisposition: 'attachment; filename="foo.txt"',
etag: '"abc123"',
}),
}),
);

await expect(
upload('foo.txt', 'Test file data', {
access: 'private',
handleUploadUrl: '/api/upload',
}),
).resolves.toMatchInlineSnapshot(`
{
"contentDisposition": "attachment; filename="foo.txt"",
"contentType": "text/plain",
"downloadUrl": "https://storeId.public.blob.vercel-storage.com/superfoo.txt?download=1",
"etag": ""abc123"",
"pathname": "foo.txt",
"url": "https://storeId.public.blob.vercel-storage.com/superfoo.txt",
}
`);

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://vercel.com/api/blob/?pathname=foo.txt',
{
body: 'Test file data',
headers: {
authorization: 'Bearer vercel_blob_client_fake_123',
'x-api-blob-request-attempt': '0',
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
'x-api-version': '12',
'x-vercel-blob-access': 'private',
},
method: 'PUT',
},
Expand Down Expand Up @@ -205,12 +268,14 @@ describe('client', () => {
1,
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
{
duplex: undefined,
headers: {
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
'x-api-blob-request-attempt': '0',
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
'x-api-version': '12',
'x-mpu-action': 'create',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: undefined,
Expand All @@ -222,6 +287,7 @@ describe('client', () => {
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
{
body: 'data1',
duplex: undefined,
headers: {
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
'x-api-blob-request-attempt': '0',
Expand All @@ -231,6 +297,7 @@ describe('client', () => {
'x-mpu-key': 'key',
'x-mpu-upload-id': 'uploadId',
'x-mpu-part-number': '1',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: internalAbortSignal,
Expand All @@ -241,6 +308,7 @@ describe('client', () => {
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
{
body: 'data2',
duplex: undefined,
headers: {
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
'x-api-blob-request-attempt': '0',
Expand All @@ -250,6 +318,7 @@ describe('client', () => {
'x-mpu-key': 'key',
'x-mpu-upload-id': 'uploadId',
'x-mpu-part-number': '2',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: internalAbortSignal,
Expand All @@ -263,6 +332,7 @@ describe('client', () => {
{ etag: 'etag1', partNumber: 1 },
{ etag: 'etag2', partNumber: 2 },
]),
duplex: undefined,
headers: {
'content-type': 'application/json',
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
Expand All @@ -272,6 +342,7 @@ describe('client', () => {
'x-mpu-action': 'complete',
'x-mpu-key': 'key',
'x-mpu-upload-id': 'uploadId',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: undefined,
Expand Down Expand Up @@ -347,12 +418,14 @@ describe('client', () => {
1,
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
{
duplex: undefined,
headers: {
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
'x-api-blob-request-attempt': '0',
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
'x-api-version': '12',
'x-mpu-action': 'create',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: undefined,
Expand All @@ -364,6 +437,7 @@ describe('client', () => {
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
{
body: 'data1',
duplex: undefined,
headers: {
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
'x-api-blob-request-attempt': '0',
Expand All @@ -373,6 +447,7 @@ describe('client', () => {
'x-mpu-key': 'key',
'x-mpu-upload-id': 'uploadId',
'x-mpu-part-number': '1',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: internalAbortSignal,
Expand All @@ -383,6 +458,7 @@ describe('client', () => {
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
{
body: 'data2',
duplex: undefined,
headers: {
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
'x-api-blob-request-attempt': '0',
Expand All @@ -392,6 +468,7 @@ describe('client', () => {
'x-mpu-key': 'key',
'x-mpu-upload-id': 'uploadId',
'x-mpu-part-number': '2',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: internalAbortSignal,
Expand All @@ -405,6 +482,7 @@ describe('client', () => {
{ etag: 'etag1', partNumber: 1 },
{ etag: 'etag2', partNumber: 2 },
]),
duplex: undefined,
headers: {
'content-type': 'application/json',
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
Expand All @@ -414,6 +492,7 @@ describe('client', () => {
'x-mpu-action': 'complete',
'x-mpu-key': 'key',
'x-mpu-upload-id': 'uploadId',
'x-vercel-blob-access': 'public',
},
method: 'POST',
signal: undefined,
Expand Down
22 changes: 14 additions & 8 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import * as crypto from 'crypto';
// the `undici` module will be replaced with https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
// for browser contexts. See ./undici-browser.js and ./package.json
import { fetch } from 'undici';
import type { BlobCommandOptions, WithUploadProgress } from './helpers';
import type {
BlobAccessType,
BlobCommandOptions,
WithUploadProgress,
} from './helpers';
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
import type { CommonCompleteMultipartUploadOptions } from './multipart/complete';
import { createCompleteMultipartUploadMethod } from './multipart/complete';
Expand All @@ -22,8 +26,10 @@ import type { PutBlobResult } from './put-helpers';
export interface ClientCommonCreateBlobOptions {
/**
* Whether the blob should be publicly accessible.
* - 'public': The blob will be publicly accessible via its URL.
* - 'private': The blob will require authentication to access.
*/
access: 'public';
access: BlobAccessType;
/**
* Defines the content type of the blob. By default, this value is inferred from the pathname.
* Sent as the 'content-type' header when downloading a blob.
Expand Down Expand Up @@ -97,7 +103,7 @@ export type ClientPutCommandOptions = ClientCommonPutOptions &
* @param pathname - The pathname to upload the blob to, including the extension. This will influence the URL of your blob.
* @param body - The content of your blob. Can be a string, File, Blob, Buffer or ReadableStream.
* @param options - Configuration options including:
* - access - (Required) Must be 'public' as blobs are publicly accessible.
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
* - contentType - (Optional) The media type for the blob. By default, it's derived from the pathname.
* - multipart - (Optional) Whether to use multipart upload for large files. It will split the file into multiple parts, upload them in parallel and retry failed parts.
Expand All @@ -121,7 +127,7 @@ export type ClientCreateMultipartUploadCommandOptions =
*
* @param pathname - A string specifying the path inside the blob store. This will be the base value of the return URL and includes the filename and extension.
* @param options - Configuration options including:
* - access - (Required) Must be 'public' as blobs are publicly accessible.
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
* - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension.
* - abortSignal - (Optional) AbortSignal to cancel the operation.
Expand All @@ -141,7 +147,7 @@ export const createMultipartUpload =
*
* @param pathname - A string specifying the path inside the blob store. This will be the base value of the return URL and includes the filename and extension.
* @param options - Configuration options including:
* - access - (Required) Must be 'public' as blobs are publicly accessible.
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
* - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension.
* - abortSignal - (Optional) AbortSignal to cancel the operation.
Expand Down Expand Up @@ -174,7 +180,7 @@ type ClientMultipartUploadCommandOptions = ClientCommonCreateBlobOptions &
* @param pathname - Same value as the pathname parameter passed to createMultipartUpload. This will influence the final URL of your blob.
* @param body - A blob object as ReadableStream, String, ArrayBuffer or Blob based on these supported body types. Each part must be a minimum of 5MB, except the last one which can be smaller.
* @param options - Configuration options including:
* - access - (Required) Must be 'public' as blobs are publicly accessible.
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
* - uploadId - (Required) A string returned from createMultipartUpload which identifies the multipart upload.
* - key - (Required) A string returned from createMultipartUpload which identifies the blob object.
Expand Down Expand Up @@ -205,7 +211,7 @@ type ClientCompleteMultipartUploadCommandOptions =
* @param pathname - Same value as the pathname parameter passed to createMultipartUpload.
* @param parts - An array containing all the uploaded parts information from previous uploadPart calls. Each part must have properties etag and partNumber.
* @param options - Configuration options including:
* - access - (Required) Must be 'public' as blobs are publicly accessible.
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
* - uploadId - (Required) A string returned from createMultipartUpload which identifies the multipart upload.
* - key - (Required) A string returned from createMultipartUpload which identifies the blob object.
Expand Down Expand Up @@ -256,7 +262,7 @@ export type UploadOptions = ClientCommonPutOptions & CommonUploadOptions;
* @param pathname - The pathname to upload the blob to. This includes the filename and extension.
* @param body - The contents of your blob. This has to be a supported fetch body type (string, Blob, File, ArrayBuffer, etc).
* @param options - Configuration options including:
* - access - (Required) Must be 'public' as blobs are publicly accessible.
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
* - handleUploadUrl - (Required) A string specifying the route to call for generating client tokens for client uploads.
* - clientPayload - (Optional) A string to be sent to your handleUpload server code. Example use-case: attaching the post id an image relates to.
* - headers - (Optional) An object containing custom headers to be sent with the request to your handleUpload route. Example use-case: sending Authorization headers.
Expand Down
9 changes: 7 additions & 2 deletions packages/blob/src/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ export async function copy(
throw new BlobError('missing options, see usage');
}

if (options.access !== 'public') {
throw new BlobError('access must be "public"');
if (options.access !== 'public' && options.access !== 'private') {
throw new BlobError(
'access must be "private" or "public", see https://vercel.com/docs/vercel-blob',
);
}

if (toPathname.length > MAXIMUM_PATHNAME_LENGTH) {
Expand All @@ -53,6 +55,9 @@ export async function copy(

const headers: Record<string, string> = {};

// access is always required, so always add it to headers
headers['x-vercel-blob-access'] = options.access;

if (options.addRandomSuffix !== undefined) {
headers['x-add-random-suffix'] = options.addRandomSuffix ? '1' : '0';
}
Expand Down
20 changes: 17 additions & 3 deletions packages/blob/src/create-folder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { requestApi } from './api';
import type { BlobCommandOptions } from './helpers';
import type { BlobAccessType, CommonCreateBlobOptions } from './helpers';
import { BlobError } from './helpers';
import { type PutBlobApiResponse, putOptionHeaderMap } from './put-helpers';

export type CreateFolderCommandOptions = Pick<
CommonCreateBlobOptions,
'token' | 'abortSignal'
> & {
/** @defaultValue 'public' — kept for backward compatibility */
access?: BlobAccessType;
};

export interface CreateFolderResult {
pathname: string;
url: string;
Expand All @@ -12,16 +21,21 @@ export interface CreateFolderResult {
*
* Use the resulting `url` to delete the folder, just like you would delete a blob.
* @param pathname - Can be user1/ or user1/avatars/
* @param options - Additional options like `token`
* @param options - Additional options including required `access` ('public' or 'private') and optional `token`
*/
// access defaults to 'public' for backward compatibility with callers
// that don't pass options (pre-private-storage API)
export async function createFolder(
pathname: string,
options: BlobCommandOptions = {},
options: CreateFolderCommandOptions = { access: 'public' },
): Promise<CreateFolderResult> {
const access = options.access ?? 'public';

const folderPathname = pathname.endsWith('/') ? pathname : `${pathname}/`;

const headers: Record<string, string> = {};

headers[putOptionHeaderMap.access] = access;
headers[putOptionHeaderMap.addRandomSuffix] = '0';

const params = new URLSearchParams({ pathname: folderPathname });
Expand Down
Loading
Loading