Share private Vercel Blob files with authenticated, expiring downloads
Invoice PDFs in a private Vercel Blob store that each customer can download only for themselves, and when to redirect to signed URLs or proxy instead.
Keep the PDFs in a private Blob store and put one route handler in front of them. The route loads the invoice row, checks that it belongs to the signed-in user, and answers with a 302 to a Vercel Signed URL that files.url() mints for 60 seconds. The browser fetches the file from Vercel’s CDN, so the bytes never pass through your Function, and a copied link stops working a minute later.
Two Vercel Blob limits shape the rest. A Blob URL can’t carry a Content-Disposition override, so the browser decides how to show the file from its stored content type. And private reads don’t support byte ranges. Neither matters for PDFs your own server generates. For files where they do matter, proxy the bytes instead.
Before you start
- A Vercel project with a private Blob store connected to it.
- An auth library that can resolve the signed-in user on the server, and a table of invoices in your database.
- Written against files-sdk 3.0,
@vercel/blob2.8, Next.js 16.4, and React 19.3.
npm install files-sdk @vercel/blobpnpm add files-sdk @vercel/blobyarn add files-sdk @vercel/blobbun add files-sdk @vercel/blobnub add files-sdk @vercel/blobaube add files-sdk @vercel/blobimport { createFiles } from "files-sdk";
import { vercelBlob } from "files-sdk/vercel-blob";
// The store ID comes from BLOB_STORE_ID. Each call authenticates with the
// OIDC token of the request it runs in.
export const files = createFiles({
adapter: vercelBlob({ access: "private" }),
});
With the store connected, the adapter needs no credentials in code: on Vercel Functions each call uses the current request’s OIDC token. Set up Vercel Blob authentication covers local development and hosts outside Vercel.
access: "private" is fixed when the adapter is created. It makes uploads private and turns url() into a presigned GET. With the default "public", url() returns the blob’s permanent CDN URL, and passing expiresIn throws an Unsupported error.
Model the invoice
Ownership lives in your database, not in the storage key. Each invoice row records who may download it and where its PDF is stored:
export interface Invoice {
id: string; // "inv_123", the ID in your download URLs
customerId: string; // the user allowed to download it
number: string; // "2026-0042", shown in the UI and the file name
blobKey: string; // "invoices/<customerId>/<id>.pdf", never sent to the browser
}
This guide assumes lib/invoices.ts exports that type plus two queries against your database: getInvoice(id), which returns an Invoice or null, and listInvoices(customerId).
The browser only ever sees the invoice ID. The route turns the ID into a blobKey after the ownership check, so a client can’t name a storage key at all, let alone climb out of its prefix.
Store each PDF privately
Wherever you generate invoices (a payment webhook, a server action, a scheduled job), upload the PDF under the row’s key with an explicit content type:
import { files } from "@/lib/files";
import type { Invoice } from "@/lib/invoices";
// Key layout: invoices/<customerId>/<invoiceId>.pdf. The server picks it;
// nothing in it comes from the browser.
export const invoiceKey = (customerId: string, invoiceId: string) =>
`invoices/${customerId}/${invoiceId}.pdf`;
export async function storeInvoicePdf(invoice: Invoice, pdf: Uint8Array) {
await files.upload(invoice.blobKey, pdf, { contentType: "application/pdf" });
}
Set blobKey with invoiceKey() when you create the row. The content type matters more than usual here: a Blob URL can’t carry download instructions, so application/pdf is what tells the browser to open its PDF viewer.
Check ownership and redirect
import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
import { getInvoice } from "@/lib/invoices";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession(request.headers);
if (!session) {
return new Response("Sign in to download invoices", { status: 401 });
}
const { id } = await params;
const invoice = await getInvoice(id);
// One answer for "no such invoice" and "not yours", so IDs can't be probed.
if (!invoice || invoice.customerId !== session.user.id) {
return new Response("Not found", { status: 404 });
}
const url = await files.url(invoice.blobKey, { expiresIn: 60 });
return new Response(null, {
headers: {
"Cache-Control": "private, no-store",
Location: url,
},
status: 302,
});
}
getSession stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check). This guide assumes it takes request headers and returns { user: { id: string } } or null. If invoices belong to an organization rather than a user, compare against the session’s organization ID instead.
Each choice in that handler is deliberate:
404for someone else’s invoice. Returning403would confirm the ID exists. The ownership check runs before any storage call, so nothing is signed for the wrong user.files.url()is scoped to one key. For each call, the adapter asks Vercel’s control API for a signing token limited to that pathname and thegetoperation, expiring with the URL, then signs the URL locally. Vercel’s CDN rejects requests outside the token’s scope, so editing the path in a leaked URL to reach another invoice fails. The cost is one round trip to Vercel per click.- 60 seconds is enough because the browser follows the redirect immediately. A shorter lifetime narrows the window in which a URL copied from history or logs still works. Vercel accepts lifetimes up to 7 days; the adapter’s default is one hour.
Cache-Control: private, no-storekeeps the redirect itself out of any cache, so a later click can’t reuse a302that points at an expired URL.
The route doesn’t use the gateway, for two reasons. The gateway scopes access by key prefix, while this route checks ownership against the invoice row and only ever shows the browser an invoice ID. And on a private Blob store the gateway’s download proxies by default: it sends Content-Disposition: attachment, which a Blob URL can’t carry, so it streams the file through your Function to set the header itself. It redirects only when authorize returns { disposition: "inline" }. This route always redirects, so the bytes stay off your Function.
Link to the route
The invoice list is a server component. Each link points at the route, not at storage:
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { listInvoices } from "@/lib/invoices";
export default async function InvoicesPage() {
const session = await getSession(await headers());
if (!session) {
redirect("/sign-in");
}
const invoices = await listInvoices(session.user.id);
return (
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
<a href={`/invoices/${invoice.id}/download`}>
Invoice {invoice.number}
</a>
</li>
))}
</ul>
);
}
Use a plain <a>: the target is a route handler that redirects, not a page. Because the link is the route and not the signed URL, it never goes stale. Users can bookmark it or find it in an email months later, and each click mints a fresh URL after a fresh session check.
What each request gets
Request to /invoices/inv_123/download |
Response |
|---|---|
| No session | 401 |
| Signed in, invoice belongs to another user | 404 |
| Signed in, no such invoice | 404 |
| Signed in as the owner | 302 to https://<store-id>.private.blob.vercel-storage.com/invoices/<customerId>/inv_123.pdf?… |
To check the isolation yourself, sign in as two users, copy one user’s download link, and open it in the other user’s session: the route answers 404 before it touches Blob. Then copy the Location from the owner’s 302 (in the browser’s network panel) and open it after a minute has passed. Vercel signs the expiry into the URL and its CDN rejects the request once it has passed. Vercel’s docs don’t say which status code it uses, so don’t build client logic on a specific one. The fix for any expired link is to click the route link again.
Within its 60 seconds, a signed URL works for whoever holds it, signed in or not. That’s the trade you make to keep bytes off your Function. If you can’t accept it for some files, proxy them.
Choose how files reach the browser
| Public blob | Private blob, signed URL (this guide) | Private blob, proxied through your route | |
|---|---|---|---|
| Who can fetch it | Anyone with the URL, with no expiry | Anyone with the URL, until it expires | Only requests your route authorizes, on every read |
| Revoking access | Delete or move the blob | Wait for the URL to expire | Immediate: change the check |
| Bytes through your Function | No | No | Yes |
Content-Disposition, file name |
Not settable (url() throws on responseContentDisposition) |
Not settable | You set it |
| Byte ranges | Supported by download() |
Not supported by download() |
Not supported by download() |
Public storage is wrong for invoices. With the adapter’s default addRandomSuffix: false, a public URL is the store’s public hostname plus the key. Anyone who has the URL, or can work out a key, can fetch the file, and the URL never expires.
The signed-URL redirect is the default for private files: one small Function invocation per click, the CDN delivers the bytes, and the exposure is a minute-long bearer URL.
Proxy when you need something a redirect can’t give: a forced download with a chosen file name, revocation that takes effect immediately, every read going through your code, or a URL that never leaves your domain. Also proxy for files your users uploaded. Those could be HTML or SVG, and a redirect would have the browser render them as-is, because Blob URLs can’t force a download.
Proxy the bytes instead
Same checks, but the route streams the file itself:
import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
import { getInvoice } from "@/lib/invoices";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession(request.headers);
if (!session) {
return new Response("Sign in to download invoices", { status: 401 });
}
const { id } = await params;
const invoice = await getInvoice(id);
if (!invoice || invoice.customerId !== session.user.id) {
return new Response("Not found", { status: 404 });
}
const file = await files.download(invoice.blobKey, { as: "stream" });
// Return the stream, not a buffered body: Vercel caps buffered Function
// responses at 4.5 MB.
return new Response(file.stream(), {
headers: {
"Cache-Control": "private, no-store",
"Content-Disposition": `attachment; filename="invoice-${invoice.number}.pdf"`,
"Content-Type": file.contentType,
"X-Content-Type-Options": "nosniff",
},
});
}
If key-prefix scoping fits your data, the gateway’s default download does the same for a private Blob store: it streams the file with Content-Disposition: attachment and X-Content-Type-Options: nosniff.
Private download() reads through @vercel/blob’s get() with the adapter’s credentials. Two platform costs come with it:
- Response size. Vercel’s Functions limits put the maximum request or response body at 4.5 MB, and its guide to that limit says streaming functions don’t have it. Return
file.stream()as above. Buffering witharrayBuffer()ortext()first makes the 4.5 MB limit apply again. - Transfer and duration. Every byte is fetched by your Function and sent again, so Vercel bills it as Fast Origin Transfer and Fast Data Transfer, and the Function runs for as long as the stream does. Vercel’s private storage docs recommend against serving files larger than 100 MB this way unless traffic is low.
Vercel’s caching advice for private blobs is Cache-Control: private, no-cache, which lets the browser keep a copy and revalidate it, and private, no-store for sensitive data such as banking details or PII. Invoices fall in the second group, so the route uses no-store.
No byte ranges on private blobs
files.download(key, { range }) on a private store throws vercel-blob: range downloads are not supported by this adapter before any request goes out, and files.capabilities.rangeRead is false. Private reads go through get(), which has no range option, and the adapter won’t fetch the whole file and pretend it returned a slice.
Invoices are read whole, so this never comes up here. If you’re serving video that has to seek, see Stream private video with HTTP Range requests, which uses a store that supports ranges.
Troubleshooting
404 for the invoice’s owner. The ownership comparison failed. Log both invoice.customerId and session.user.id; a common mismatch is comparing your database’s user ID with your auth provider’s ID.
vercel-blob: `responseContentDisposition` is not supported… Something passed a disposition to url(): your own code, or a gateway configured with downloadMode: "redirect", which has no proxy to fall back to. Call files.url() without it, as the route above does, or proxy the file and set the header yourself.
url: this adapter cannot set Content-Disposition on its URLs, so the gateway cannot guarantee "attachment".… with a 422 (code Unsupported) from the gateway’s url operation. The gateway refuses to mint a Blob URL it can’t force to download. Return { disposition: "inline" } from authorize for routes where inline rendering is acceptable, or serve the file through download or a route like the one in this guide.
vercel-blob: range downloads are not supported by this adapter. A range reached download() on the private store. Read the whole file, or use a store with range support for media.
vercelBlob adapter: missing credentials… or Vercel Blob: Access denied, please provide a valid token for this resource. The adapter couldn’t authenticate. Set up Vercel Blob authentication covers each cause.
The PDF downloads instead of opening, or opens as text. The browser goes by the stored content type. Check that the upload passed contentType: "application/pdf"; files.head(key) returns the stored contentType.