Private StorageBeta
Vercel Blob is available on all plans
Those with the owner, member, developer role can access this feature
Private storage requires a private Blob store and is available starting with:
@vercel/blobTypeScript SDK >= 2.3vercelPython SDK >= 0.5.0vcVercel CLI >= 50.20.0
Private Blob stores require authentication for all read and write operations, ensuring files are only accessible to authenticated requests. Use private storage for sensitive documents, user content, and applications with custom authentication.
See differences with public storage.
You can create a private Blob store from the Vercel dashboard or the CLI:
- Dashboard:
- Go to your project's Storage tab
- Select Create Database, then choose Blob
- Select Continue, then set the access to Private
- CLI: Run
vercel blob create-store [name] --access private
Your project needs a BLOB_READ_WRITE_TOKEN environment variable to interact with the store. When you create a Blob store from a project, this variable is added automatically. When you create a store from the team level, you'll need to connect it to a project for the environment variable to be added.
Upload files to a private Blob store using put() with access: 'private':
import { put } from '@vercel/blob';
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get('file') as File;
const blob = await put(file.name, file, {
access: 'private',
});
return Response.json(blob);
}See the server uploads and client uploads guides for detailed instructions on uploading files.
Every file uploaded to a private Blob store gets a URL in the form of https://<store-id>.private.blob.vercel-storage.com/<pathname>. This URL is not publicly accessible. You can only fetch its content using the get() SDK method or by passing the BLOB_READ_WRITE_TOKEN directly (see accessing without the SDK).
To serve private blobs to your users, create a route that authenticates the request, fetches the blob using get(), and streams the response. For example, a route like https://example.com/api/file?pathname=documents/report.pdf:
import { type NextRequest, NextResponse } from 'next/server';
import { get } from '@vercel/blob';
export async function GET(request: NextRequest) {
// Your auth goes here: await authRequest(request)
const pathname = request.nextUrl.searchParams.get('pathname');
if (!pathname) {
return NextResponse.json({ error: 'Missing pathname' }, { status: 400 });
}
const result = await get(pathname, { access: 'private' });
if (result?.statusCode !== 200) {
return new NextResponse('Not found', { status: 404 });
}
return new NextResponse(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
},
});
}import { NextResponse } from 'next/server';
import { get } from '@vercel/blob';
export async function GET(request) {
// Your auth goes here: await authRequest(request)
const pathname = request.nextUrl.searchParams.get('pathname');
if (!pathname) {
return NextResponse.json({ error: 'Missing pathname' }, { status: 400 });
}
const result = await get(pathname, { access: 'private' });
if (result?.statusCode !== 200) {
return new NextResponse('Not found', { status: 404 });
}
return new NextResponse(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
},
});
}import type { NextApiRequest, NextApiResponse } from 'next';
import { Readable } from 'node:stream';
import { get } from '@vercel/blob';
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
// Your auth goes here: await authRequest(request)
const pathname = request.query.pathname as string;
if (!pathname) {
return response.status(400).json({ error: 'Missing pathname' });
}
const result = await get(pathname, { access: 'private' });
if (result?.statusCode !== 200) {
return response.status(404).send('Not found');
}
response.setHeader('Content-Type', result.blob.contentType);
Readable.fromWeb(result.stream).pipe(response);
}import { Readable } from 'node:stream';
import { get } from '@vercel/blob';
export default async function handler(request, response) {
// Your auth goes here: await authRequest(request)
const { pathname } = request.query;
if (!pathname) {
return response.status(400).json({ error: 'Missing pathname' });
}
const result = await get(pathname, { access: 'private' });
if (result?.statusCode !== 200) {
return response.status(404).send('Not found');
}
response.setHeader('Content-Type', result.blob.contentType);
Readable.fromWeb(result.stream).pipe(response);
}import { get } from '@vercel/blob';
export default async function handler(request: Request) {
// Your auth goes here: await authRequest(request)
const { searchParams } = new URL(request.url);
const pathname = searchParams.get('pathname');
if (!pathname) {
return new Response(JSON.stringify({ error: 'Missing pathname' }), {
status: 400,
});
}
const result = await get(pathname, { access: 'private' });
if (result?.statusCode !== 200) {
return new Response('Not found', { status: 404 });
}
return new Response(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
},
});
}import { get } from '@vercel/blob';
export default async function handler(request) {
// Your auth goes here: await authRequest(request)
const { searchParams } = new URL(request.url);
const pathname = searchParams.get('pathname');
if (!pathname) {
return new Response(JSON.stringify({ error: 'Missing pathname' }), {
status: 400,
});
}
const result = await get(pathname, { access: 'private' });
if (result?.statusCode !== 200) {
return new Response('Not found', { status: 404 });
}
return new Response(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
},
});
}See the get() API reference for full option details and return values.
You can access private blobs directly using the BLOB_READ_WRITE_TOKEN:
curl https://my-store-id.private.blob.vercel-storage.com/my-file.png \
-H "Authorization: Bearer $BLOB_READ_WRITE_TOKEN"Private blobs have two layers of caching:
- CDN cache (between your Function and the blob store): When your Function fetches a private blob, the request goes through Vercel's CDN cache. If the blob is already cached, no Fast Origin Transfer is charged. You control the CDN cache duration with the
cacheControlMaxAgeoption when uploading (defaults to 1 month), the same way as public blobs. - Browser cache (between the browser and your Function): You control this through the
Cache-Controlheader on your Function's response.
If you don't set a Cache-Control header, Vercel sends Cache-Control: public, max-age=0, must-revalidate by default. The browser keeps the response on disk but always revalidates with your server before using it, so your auth logic runs on every request and the user always gets the correct content.
Since you're serving private blobs, we recommend setting Cache-Control: private, no-cache. This restricts caching to the browser only while still allowing efficient 304 responses:
const result = await get(pathname, { access: 'private' });
return new NextResponse(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
'Cache-Control': 'private, no-cache',
},
});For sensitive data (tokens, banking, PII), use Cache-Control: private, no-store instead. Nothing is stored on disk and every request is a full fetch.
When you set Cache-Control: private, no-cache, the browser caches the response but revalidates on every request by sending an If-None-Match header with the ETag it received previously. You can forward this header to the blob store to get a 304 Not Modified response when the blob hasn't changed, avoiding re-downloading the full file:
import { type NextRequest, NextResponse } from 'next/server';
import { get } from '@vercel/blob';
export async function GET(request: NextRequest) {
// Your auth goes here: await authRequest(request)
const pathname = request.nextUrl.searchParams.get('pathname');
if (!pathname) {
return NextResponse.json({ error: 'Missing pathname' }, { status: 400 });
}
const result = await get(pathname, {
access: 'private',
ifNoneMatch: request.headers.get('if-none-match') ?? undefined,
});
if (!result) {
return new NextResponse('Not found', { status: 404 });
}
// Blob hasn't changed — tell the browser to use its cached copy
if (result.statusCode === 304) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}
return new NextResponse(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}import { NextResponse } from 'next/server';
import { get } from '@vercel/blob';
export async function GET(request) {
// Your auth goes here: await authRequest(request)
const pathname = request.nextUrl.searchParams.get('pathname');
if (!pathname) {
return NextResponse.json({ error: 'Missing pathname' }, { status: 400 });
}
const result = await get(pathname, {
access: 'private',
ifNoneMatch: request.headers.get('if-none-match') ?? undefined,
});
if (!result) {
return new NextResponse('Not found', { status: 404 });
}
// Blob hasn't changed — tell the browser to use its cached copy
if (result.statusCode === 304) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}
return new NextResponse(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}import type { NextApiRequest, NextApiResponse } from 'next';
import { Readable } from 'node:stream';
import { get } from '@vercel/blob';
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
// Your auth goes here: await authRequest(request)
const pathname = request.query.pathname as string;
if (!pathname) {
return response.status(400).json({ error: 'Missing pathname' });
}
const result = await get(pathname, {
access: 'private',
ifNoneMatch: (request.headers['if-none-match'] as string) ?? undefined,
});
if (!result) {
return response.status(404).send('Not found');
}
// Blob hasn't changed — tell the browser to use its cached copy
if (result.statusCode === 304) {
response.setHeader('ETag', result.blob.etag);
response.setHeader('Cache-Control', 'private, no-cache');
return response.status(304).end();
}
response.setHeader('Content-Type', result.blob.contentType);
response.setHeader('ETag', result.blob.etag);
response.setHeader('Cache-Control', 'private, no-cache');
Readable.fromWeb(result.stream).pipe(response);
}import { Readable } from 'node:stream';
import { get } from '@vercel/blob';
export default async function handler(request, response) {
// Your auth goes here: await authRequest(request)
const { pathname } = request.query;
if (!pathname) {
return response.status(400).json({ error: 'Missing pathname' });
}
const result = await get(pathname, {
access: 'private',
ifNoneMatch: request.headers['if-none-match'] ?? undefined,
});
if (!result) {
return response.status(404).send('Not found');
}
// Blob hasn't changed — tell the browser to use its cached copy
if (result.statusCode === 304) {
response.setHeader('ETag', result.blob.etag);
response.setHeader('Cache-Control', 'private, no-cache');
return response.status(304).end();
}
response.setHeader('Content-Type', result.blob.contentType);
response.setHeader('ETag', result.blob.etag);
response.setHeader('Cache-Control', 'private, no-cache');
Readable.fromWeb(result.stream).pipe(response);
}import { get } from '@vercel/blob';
export default async function handler(request: Request) {
// Your auth goes here: await authRequest(request)
const { searchParams } = new URL(request.url);
const pathname = searchParams.get('pathname');
if (!pathname) {
return new Response(JSON.stringify({ error: 'Missing pathname' }), {
status: 400,
});
}
const result = await get(pathname, {
access: 'private',
ifNoneMatch: request.headers.get('if-none-match') ?? undefined,
});
if (!result) {
return new Response('Not found', { status: 404 });
}
// Blob hasn't changed — tell the browser to use its cached copy
if (result.statusCode === 304) {
return new Response(null, {
status: 304,
headers: {
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}
return new Response(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}import { get } from '@vercel/blob';
export default async function handler(request) {
// Your auth goes here: await authRequest(request)
const { searchParams } = new URL(request.url);
const pathname = searchParams.get('pathname');
if (!pathname) {
return new Response(JSON.stringify({ error: 'Missing pathname' }), {
status: 400,
});
}
const result = await get(pathname, {
access: 'private',
ifNoneMatch: request.headers.get('if-none-match') ?? undefined,
});
if (!result) {
return new Response('Not found', { status: 404 });
}
// Blob hasn't changed — tell the browser to use its cached copy
if (result.statusCode === 304) {
return new Response(null, {
status: 304,
headers: {
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}
return new Response(result.stream, {
headers: {
'Content-Type': result.blob.contentType,
ETag: result.blob.etag,
'Cache-Control': 'private, no-cache',
},
});
}How it works:
- First request:
get()returnsstatusCode: 200withstreamand anETag. The browser caches the response. - Subsequent requests: The browser sends
If-None-Matchwith the cached ETag. When forwarded to the blob store,get()returnsstatusCode: 304withstream: null— no data is re-downloaded.
Avoid caching private blob responses in Vercel's CDN cache (e.g. with
s-maxage) and avoid relying on middleware for auth. While both can work, a
middleware bug or misconfiguration could expose cached private content to the
wrong users.
Instead, always verify auth directly in your route handler, right next to the
get() call.
See Cache-Control headers for more details.
Learn more about Vercel's CDN cache.
Since private blobs are delivered through your own Functions, you can serve them from any custom domain. A common pattern is to create a dedicated Vercel project (e.g. "assets") for blob delivery:
- Create a new Vercel project with a route handler that serves private blobs (as shown in delivering private blobs)
- Connect your private Blob store to this project
- Assign a custom domain to the project (e.g.
content.mywebsite.com)
Requests to content.mywebsite.com/api/file?pathname=documents/report.pdf will then go through your auth logic and stream the blob to the user.
Private blobs cannot be indexed by search engines since all read access requires authentication.
When serving private blobs through Functions, you pay for:
- Function fetches the blob from the store: Blob Data Transfer + Fast Origin Transfer on cache miss
- Function responds to the browser: Fast Data Transfer + Fast Origin Transfer
Recommendations:
- Private Blob stores are ideal for smaller sensitive files or when you need precise auth control
- We do not recommend serving files larger than 100 MB through private Blob stores unless traffic is low
- For large public media, use public storage which benefits from BDT rates (3x cheaper than FDT)
See pricing documentation for full details.
Upload charges depend on your implementation method:
- Client Uploads: No data transfer charges for uploads
- Server Uploads: Fast Data Transfer charges apply when your Vercel application receives the file
See pricing documentation for full details.
Was this helpful?