Skip to content
Docs
 

Vercel Signed URLs

Vercel Blob is available on all plans

Those with the owner, member, developer role can access this feature

Vercel Signed URLs grant time-limited access to a Blob URL without exposing a read-write token. Issue a short-lived signed token on your server, then sign individual blob URLs from the browser or any client. The CDN verifies the signature and rejects requests that fall outside the token's scope.

Use Signed URLs when you want to:

  • Share a private blob with a third party for a limited window.
  • Embed a private blob in a server-rendered page without proxying bytes through your function.
  • Let a browser upload directly to your Blob store without holding a read-write token.
  • Let a client delete or inspect a specific blob without granting full read-write access.

The issueSignedToken method runs on your server and asks the Blob control API for a short-lived token that you can use to sign presigned URLs. It uses the same authentication as the rest of the SDK (OIDC or a static read-write token).

issueSignedToken(options);

It accepts a single options object:

ParameterRequiredValues
pathnameNoThe blob pathname to scope the token to, for example media/photo.png. Use * to allow any pathname in the store. Defaults to a whole-store wildcard.
operationsNoAn array of allowed operations: 'get', 'head', 'put', or 'delete'. Defaults to ['get']. 'get' covers GET requests against the blob URL; 'head' covers HEAD requests against the same URL (signed separately so a 'get' token cannot be replayed as HEAD); 'put' covers single-object PUT and multipart POST against the Blob API; 'delete' covers DELETE against the Blob API.
validUntilNoA timestamp in milliseconds since the epoch when the token expires. Maximum 7 days from now. Defaults to 1 hour from now.
allowedContentTypesNoAn array of MIME types the token accepts on 'put' operations, for example ['image/png', 'image/jpeg']. Embedded in the delegation payload so the API enforces it on every upload.
maximumSizeInBytesNoMaximum upload size in bytes, enforced on 'put' operations. Embedded in the delegation payload.
tokenNoA static read-write token. See Authentication.
oidcTokenNoA Vercel OIDC token, used in place of process.env.VERCEL_OIDC_TOKEN. Pair with storeId (or BLOB_STORE_ID). See Authentication.
storeIdNoThe Blob store id, used with OIDC. See Authentication.
abortSignalNoAn AbortSignal to cancel the request.

issueSignedToken() returns:

{
  delegationToken: string;       // public; encodes scope and HMAC, travels publicly in every signed URL
  clientSigningToken: string;    // secret; HMAC key used to sign URLs within the delegation's scope
  validUntil: number;            // ms since epoch
}

The delegationToken is safe to share with clients. It travels inside every signed URL the CDN sees. Treat the clientSigningToken as a secret: anyone who holds it can sign URLs within the delegation's scope.

The presignUrl method takes the material returned by issueSignedToken and produces a ready-to-fetch URL for a specific pathname. It is HMAC-only and runs in any environment (server, edge, browser). Use it to sign 'get', 'head', 'put', and 'delete' operations.

presignUrl(signedToken, options);

It accepts the following parameters:

  • signedToken: (Required) The { delegationToken, clientSigningToken } returned by issueSignedToken.
  • options: (Required) An object describing what to sign:
ParameterRequiredValues
operationYes'get', 'head', 'put', or 'delete'. Must be included in the operations array of the issueSignedToken call.
pathnameYesThe blob pathname to sign, for example media/photo.png. Must match the delegation's pathname scope.
accessYes'public' or 'private', matching the store's access mode.
validUntilNoA timestamp in milliseconds since the epoch when this specific URL expires. Capped to the delegation's validUntil. Serialized as the vercel-blob-valid-until query parameter and signed.

For 'put' operations, options also accepts upload-shaping fields (allowedContentTypes, maximumSizeInBytes, addRandomSuffix, allowOverwrite, cacheControlMaxAge, ifMatch, and onUploadCompleted).

For 'get' operations, options also accepts useCache. Set it to false to append a cache=0 query parameter so fetches bypass the CDN cache and read the latest content directly from origin storage. See the GET example below.

For 'delete' operations, options also accepts ifMatch to require a specific ETag on the target blob. 'head' accepts no additional fields beyond the ones in the table above.

presignUrl() returns:

{
  presignedUrl: string;
}

Each operation in the delegation token's operations array can be signed into a URL with presignUrl. The four examples below cover every operation supported by the SDK.

Fetch the presigned URL with a standard GET request.

import { issueSignedToken, presignUrl } from '@vercel/blob';
 
// Server: issue a signed token. `issueSignedToken` calls the Blob
// control API, so cache the result and reuse it across requests until it's
// near expiry to avoid a network round-trip on every URL you sign.
const token = await issueSignedToken({
  pathname: 'media/photo.png',
  operations: ['get'],
  validUntil: Date.now() + 60 * 60 * 1000,
});
 
// Sign a URL valid for 5 minutes, even though the token is valid for 1 hour.
const { presignedUrl } = await presignUrl(token, {
  operation: 'get',
  pathname: 'media/photo.png',
  access: 'private',
  validUntil: Date.now() + 5 * 60 * 1000,
});
 
const res = await fetch(presignedUrl);

Fetches of a presigned GET URL 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, so the URL can return the previous version during that window. When the fetch must reflect the latest write, pass useCache: false:

const { presignedUrl } = await presignUrl(token, {
  operation: 'get',
  pathname: 'media/photo.png',
  access: 'private',
  useCache: false, // appends cache=0 so fetches read from origin storage
});
 
const res = await fetch(presignedUrl);

Use a 'head'-signed URL to check whether a blob exists and read its response headers (content type, size, cache headers) without transferring the blob's bytes.

import { issueSignedToken, presignUrl } from '@vercel/blob';
 
const token = await issueSignedToken({
  pathname: 'media/photo.png',
  operations: ['head'],
  validUntil: Date.now() + 60 * 60 * 1000,
});
 
const { presignedUrl } = await presignUrl(token, {
  operation: 'head',
  pathname: 'media/photo.png',
  access: 'private',
});
 
const res = await fetch(presignedUrl, { method: 'HEAD' });
const contentLength = res.headers.get('content-length');
const contentType = res.headers.get('content-type');

Call presignUrl directly when you need a one-off upload URL outside of handleUploadPresigned, for example from a CLI or a server-to-server job. The PUT options enforce constraints on the upload at the CDN, so the URL is safe to hand to a client even if the client controls the request body:

  • allowedContentTypes rejects requests whose Content-Type header is not in the list.
  • maximumSizeInBytes rejects requests larger than the configured size.
  • addRandomSuffix and allowOverwrite control collision behavior on the target pathname.
  • ifMatch requires the existing blob at the pathname to have a specific ETag, so an upload only succeeds if the blob has not changed since you last read it (conditional write).
  • validUntil shortens the URL's lifetime below the delegation token's expiry.
import { head, issueSignedToken, presignUrl } from '@vercel/blob';
 
const { etag } = await head(
  'https://<storeId>.private.blob.vercel-storage.com/media/photo.png',
);
 
const token = await issueSignedToken({
  pathname: 'media/photo.png',
  operations: ['put'],
  allowedContentTypes: ['image/png'],
  maximumSizeInBytes: 10 * 1024 * 1024,
  validUntil: Date.now() + 60 * 60 * 1000,
});
 
const { presignedUrl } = await presignUrl(token, {
  operation: 'put',
  pathname: 'media/photo.png',
  access: 'private',
  allowedContentTypes: ['image/png'],
  maximumSizeInBytes: 10 * 1024 * 1024,
  addRandomSuffix: false,
  allowOverwrite: true,
  ifMatch: etag,
});
 
const res = await fetch(presignedUrl, {
  method: 'PUT',
  headers: { 'content-type': 'image/png' },
  body: pngBytes,
});

Call presignUrl directly when you need a one-off delete URL, for example from a CLI or a server-to-server job. Pass ifMatch to require a specific ETag so the delete only succeeds if the blob has not changed since you last read it.

import { head, issueSignedToken, presignUrl } from '@vercel/blob';
 
const { etag } = await head(
  'https://<storeId>.private.blob.vercel-storage.com/media/photo.png',
);
 
const token = await issueSignedToken({
  pathname: 'media/photo.png',
  operations: ['delete'],
  validUntil: Date.now() + 60 * 60 * 1000,
});
 
const { presignedUrl } = await presignUrl(token, {
  operation: 'delete',
  pathname: 'media/photo.png',
  access: 'private',
  ifMatch: etag,
});
 
const res = await fetch(presignedUrl, { method: 'DELETE' });

Vercel Signed URLs also support browser-to-blob uploads. Instead of generating a single-use client token with handleUpload, your server returns presigned PUT URLs. The browser uploads directly to the Blob control plane with no Vercel-managed bearer token in flight.

The handleUploadPresigned server-side route helper is the presigned counterpart of handleUpload. Imported from @vercel/blob/client, it issues presigned upload URLs for clients and verifies the upload-completed callback the same way handleUpload does.

handleUploadPresigned(options);
ParameterRequiredValues
bodyYesThe parsed request JSON, typed as HandleUploadPresignedBody.
requestYesThe incoming Request. Used to verify the onUploadCompleted callback signature.
webhookPublicKeyNoThe public key used to verify the onUploadCompleted callback. Defaults to process.env.BLOB_WEBHOOK_PUBLIC_KEY. Note that when uploads are done via presigned URLs, this key is used for callback signature verification, instead of the read-write token.
getSignedTokenYesAn async function called with (pathname, clientPayload, multipart) that returns { token, urlOptions? }. token is an IssuedSignedToken from issueSignedToken, scoped with operations: ['put']. urlOptions constrains the resulting upload URL. You must authenticate and authorize the user inside this function. Otherwise, your upload route allows anonymous uploads to your Blob store.
onUploadCompletedNoSame shape as in handleUpload. Useful for updating your database after the upload finishes.

The route handler authenticates the request, issues a token scoped to 'put', and returns the constraints that should be embedded in the presigned URL. In production, cache the token across requests to avoid issuing a new token on every upload.

app/api/upload-presigned/route.ts
import { issueSignedToken } from '@vercel/blob';
import {
  type HandleUploadPresignedBody,
  handleUploadPresigned,
} from '@vercel/blob/client';
import { NextResponse } from 'next/server';
 
async function authenticate(request: Request): Promise<{ id: string, plan: 'hobby' | 'pro' } | null> {
  // Replace with your real auth check.
  return { id: 'user_123', plan: 'pro' };
}
 
export async function POST(request: Request): Promise<NextResponse> {
  const body = (await request.json()) as HandleUploadPresignedBody;
 
  try {
    const jsonResponse = await handleUploadPresigned({
      body,
      request,
      getSignedToken: async (pathname) => {
        const user = await authenticate(request);
        if (!user) throw new Error('Not authorized');
 
        // You can cache this token until validUntil
        const token = await issueSignedToken({
          pathname,
          operations: ['put'],
          allowedContentTypes: ['image/png', 'image/jpeg', 'video/mp4'],
          maximumSizeInBytes: 10 * 1024 * 1024,
          validUntil: Date.now() + 60 * 60 * 1000,
        });
 
        // Give tighter restrictions to hobby users.
        const allowedContentTypes = user.plan === 'hobby' ? ['image/png', 'image/jpeg'] : undefined;
        const maximumSizeInBytes = user.plan === 'hobby' ? 5 * 1024 * 1024 : undefined;
 
        return {
          token,
          urlOptions: {
            allowedContentTypes,
            maximumSizeInBytes,
            validUntil: Date.now() + 10 * 60 * 1000,
            addRandomSuffix: true,
            allowOverwrite: false,
            cacheControlMaxAge: 30 * 24 * 60 * 60,
          },
        };
      },
      onUploadCompleted: async ({ blob, tokenPayload }) => {
        // Persist `blob.url` or update your database here.
      },
    });
 
    return NextResponse.json(jsonResponse);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return NextResponse.json(
      { error: message },
      { status: message === 'Not authorized' ? 401 : 400 },
    );
  }
}

From the browser, call uploadPresigned from @vercel/blob/client with the route URL. The SDK fetches a presigned URL and streams the file directly to Blob storage with no Authorization header in flight:

app/upload/page.tsx
'use client';
 
import { uploadPresigned, type PutBlobResult } from '@vercel/blob/client';
import { useRef, useState } from 'react';
 
export default function UploadPage(): React.JSX.Element {
  const inputRef = useRef<HTMLInputElement>(null);
  const [blob, setBlob] = useState<PutBlobResult | null>(null);
 
  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault();
        const file = inputRef.current?.files?.[0];
        if (!file) return;
 
        const result = await uploadPresigned(file.name, file, {
          access: 'private',
          handleUploadUrl: '/api/upload-presigned',
        });
        setBlob(result);
      }}
    >
      <input ref={inputRef} type="file" />
      <button type="submit">Upload</button>
      {blob ? <a href={blob.url}>{blob.url}</a> : null}
    </form>
  );
}

If you already use handleUpload, the upgrade is mostly mechanical. The example above shows the full new form. The changes from the legacy flow are:

On the server, swap handleUpload for handleUploadPresigned and mint the token inside getSignedToken. The constraints you used to return from onBeforeGenerateToken move into urlOptions; onUploadCompleted keeps the same shape, so database writes carry over unchanged.

import { issueSignedToken } from '@vercel/blob';
import {
  type HandleUploadPresignedBody,
  handleUploadPresigned,
} from '@vercel/blob/client';
 
await handleUploadPresigned({
  body,
  request,
  getSignedToken: async (pathname) => ({
    token: await issueSignedToken({ pathname, operations: ['put'] }),
    urlOptions: {
      allowedContentTypes: ['image/png', 'image/jpeg'],
      addRandomSuffix: true,
      allowOverwrite: false,
      cacheControlMaxAge: 30 * 24 * 60 * 60,
    },
  }),
  onUploadCompleted: async ({ blob, tokenPayload }) => {
    // Same shape as `handleUpload` — your database writes carry over.
  },
});

Also set BLOB_WEBHOOK_PUBLIC_KEY in your project's environment so handleUploadPresigned can verify the upload-completed callback. Existing connections can opt in from the store's connection menu.

On the client:

  • Replace upload with uploadPresigned. Both come from @vercel/blob/client.
  • Point handleUploadUrl at the new presigned route handler.
  • The result type is PutBlobResult. If you were typing the awaited value with BlobResult, update it.
Last updated September 15, 2026

Was this helpful?

supported.