Private Storage
Vercel Blob is available on all plans
Those with the owner, member, developer role can access this feature
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
When your code runs on Vercel, the SDK authenticates using OIDC by default. Connect your store to a project so Vercel adds the OIDC token and BLOB_STORE_ID to your environment automatically. For team-level stores or code that runs outside Vercel, see Authentication.
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. Fetch its content with the get() SDK method. The SDK supports OpenID Connect (OIDC) and BLOB_READ_WRITE_TOKEN authentication. When your code runs on Vercel, it defaults to OIDC, which is preferred because its short-lived tokens rotate automatically. 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,
'X-Content-Type-Options': 'nosniff',
},
});
}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,
'X-Content-Type-Options': 'nosniff',
},
});
}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);
response.setHeader('X-Content-Type-Options', 'nosniff');
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);
response.setHeader('X-Content-Type-Options', 'nosniff');
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,
'X-Content-Type-Options': 'nosniff',
},
});
}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,
'X-Content-Type-Options': 'nosniff',
},
});
}See the get() API reference for full option details and return values.
To access a private blob directly from code running on Vercel, pass the short-lived VERCEL_OIDC_TOKEN in the Authorization header. OIDC is preferred over the long-lived BLOB_READ_WRITE_TOKEN because it rotates automatically:
curl https://my-store-id.private.blob.vercel-storage.com/my-file.png \
-H "Authorization: Bearer $VERCEL_OIDC_TOKEN"When code runs outside Vercel or you need a static credential, pass BLOB_READ_WRITE_TOKEN instead:
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,
'X-Content-Type-Options': 'nosniff',
'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.
Reads are served through Vercel's CDN cache by default. When you overwrite a blob at the same pathname, the changes may take up to 60 seconds to propagate through our cache. During that window, get() can return the previous version. When a read must reflect the latest write, such as fetching a file right after updating it, pass useCache: false:
const result = await get(pathname, { access: 'private', useCache: false });This serves the read directly from origin storage and guarantees the latest content. These reads are slower and incur Fast Origin Transfer on each request. Keep the default for content that doesn't change after upload, such as blobs uploaded with unique pathnames or with addRandomSuffix: true.
Presigned GET URLs accept the same useCache option in presignUrl().
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,
'X-Content-Type-Options': 'nosniff',
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,
'X-Content-Type-Options': 'nosniff',
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('X-Content-Type-Options', 'nosniff');
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('X-Content-Type-Options', 'nosniff');
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,
'X-Content-Type-Options': 'nosniff',
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,
'X-Content-Type-Options': 'nosniff',
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.
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?