Skip to content

Vercel Presigned URLs#1057

Merged
elliotdauber merged 44 commits into
elliot/oidc-authfrom
elliot/presigned-urls
May 18, 2026
Merged

Vercel Presigned URLs#1057
elliotdauber merged 44 commits into
elliot/oidc-authfrom
elliot/presigned-urls

Conversation

@elliotdauber
Copy link
Copy Markdown
Collaborator

@elliotdauber elliotdauber commented Apr 23, 2026

Implements presigned urls

Requests signing token from the server, then can use it to sign urls

import {
  issueSignedToken,
  presignUrl,
} from '@vercel/blob';

const signingToken = await issueSignedToken({
  pathname: '*',
  operations: ['get']
  ttlSeconds: 10 * 60
});

const blobUrl = 'https://<storeId>.public.blob.vercel-storage.com/media/photo.png';

const presigned = await presignUrl(blobUrl, signingToken, 'GET', { ttlSeconds: 5 * 60 });

const res = await fetch(presigned);

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vercel-storage-next-integration-test-suite Ready Ready Preview May 18, 2026 5:49pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

⚠️ No Changeset found

Latest commit: 7f0ab50

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

options.ifMatch !== undefined
) {
throw new BlobError(
"client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i need to update messaging here

Comment thread packages/blob/src/client.ts Outdated
webhookPublicKey ?? process.env.BLOB_WEBHOOK_PUBLIC_KEY;

if (!resolvedWebhookPublicKey) {
throw new BlobError('Missing webhook public key');
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we error here? or only when doing webhook callback, if onUploadCompleted is provided

Comment thread packages/blob/src/helpers.ts Outdated
Comment thread packages/blob/src/signed-token.ts Outdated
* {@link HandleUploadPresignedIssuanceContext} to `getSignedToken` so you can include it in
* `issueSignedToken`.
*/
export async function handleUploadPresigned({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't export this in the client

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's where handleUpload is exported so its gonna be where it's easiest for people to find when upgrading

* - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\})
* @returns A promise that resolves to the blob information, including pathname, contentType, contentDisposition, url, and downloadUrl.
*/
export const uploadPresigned = createPutMethod<UploadOptions>({
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean

* - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\})
* @returns A promise that resolves to the blob information, including pathname, contentType, contentDisposition, url, and downloadUrl.
*/
export const uploadPresigned = createPutMethod<UploadOptions>({
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean

elliotdauber and others added 25 commits May 18, 2026 10:48
* feat(blob): presigned DELETE URLs

Wires the `'delete'` DelegationOperation (added in #1061, since rebased
out of elliot/presigned-urls) through `presignUrl`. A token issued with
`operations: ['delete']` can now mint a presigned `DELETE /?pathname=…`
against the control-plane API.

- `PresignDeleteUrlOptions` accepts `pathname`, optional `validUntil`,
  optional `ifMatch`. Upload-only fields are rejected at the type level.
- `presign()` gates on the delegation scope including `'delete'`.
- `buildPresignedDeleteUrl()` mirrors the PUT URL shape; the HTTP method
  is the discriminator (canonical signing string carries `operation=delete`).
- `buildPresignCanonicalQueryEntries()` for `delete` emits only
  `validUntil` (when below the delegation ceiling) and `ifMatch`.
- E2E test route + delete button on the presigned-upload demo page.

Based on `elliot/presigned-urls`, not `main`.

* feat(blob): presigned HEAD URLs (#1065)

`HEAD` mirrors `GET` against the blob object host
(`<storeId>.<access>.blob.vercel-storage.com/<pathname>`); the URL shape
is identical and the HTTP method is the discriminator. `operation=head`
goes into the canonical signing string so a GET-signed URL cannot be
replayed as a HEAD (and vice versa).

- `'head'` added to `DelegationOperation`.
- `PresignHeadUrlOptions` — same shape as `PresignGetUrlOptions`
  (`pathname`, optional `validUntil`).
- `presign()` gates on the delegation scope including `'head'`.
- `presignUrl()` reuses `buildPresignedGetUrl()` for `operation: 'head'`.
- `buildPresignCanonicalQueryEntries()` for `head` emits only
  `validUntil` (when below the delegation ceiling) — same as `get`.
- E2E test route + HEAD button on the presigned-upload demo page.

Stacked on `falcoagustin/presigned-delete-impl` (#1064).
@elliotdauber elliotdauber force-pushed the elliot/presigned-urls branch from 93eaf90 to 7f0ab50 Compare May 18, 2026 17:48
@elliotdauber elliotdauber merged commit e527fad into elliot/oidc-auth May 18, 2026
3 checks passed
@elliotdauber elliotdauber deleted the elliot/presigned-urls branch May 18, 2026 17:54
elliotdauber added a commit that referenced this pull request May 18, 2026
* Add Vercel OIDC auth

* up

* header

* up

* prioritize token

* normalize storeId in OIDC auth resolution

BLOB_STORE_ID and the storeId option are accepted in either store_<id>
or <id> form (Vercel env pull writes the prefixed form), and may be
mixed-case. resolveBlobAuth was passing those through verbatim, so the
storeId in API headers and CDN host subdomains could be malformed —
e.g. blob.get against a private store with `store_WdsHBk1w9fDO4vPW`
built `https://store_WdsHBk1w9fDO4vPW.private.blob.vercel-storage.com/...`
and 404'd. The RW path was unaffected because parseStoreIdFromReadWriteToken
yields a bare lowercase id from the token's structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* preserve case when normalizing storeId

The first pass also lowercased — that breaks API requests, since the
Vercel Blob API is case-sensitive on the storeId (header and bearer
parsing). The CDN host accepts either case, so prefix-strip alone is
sufficient and works for both consumers. Verified end-to-end against
a private store: blob get and blob list both succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add oidcToken option

* minor version

* Vercel Presigned URLs (#1057)

* presigned urls

* up

* normalize

* fix

* update to new signing string

* [wip] presigned url version of handleUpload (#1059)

* [wip] presigned url version of handleUpload

* up

* up

* up

* add presigned urls for mpu

* update options shape

* webhook signature

* update example

* up

* up

* up

* change to operation

* up

* up

* verify webhook signature

* up

* callbackUrl

* non-null

* presigned url opts

* new delegation token + url opts

* dont lowercase store id

* validUntil instead of ttlSeconds

* up

* up

* Add 'delete' to DelegationOperation for presigned DELETE URLs (#1061)

Mirrors the API-side change that adds `delete` to the issue_signed_token
allowed operations. `presignUrl` now signs canonical
`operation=delete\npathname=...` against the public blob object URL
(same host shape as `get`/`head`, just used with HTTP DELETE).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* change upload to put

* delete

* up

* presigned url payload

* update app

* fix

* docs

* refactor presign url

* up

* up

* up

* add head

* up

* add params to url

* add keys first

* token payload

* add buildPresignedGetUrl

* up

* presignUrl returns url, not accepted in sdk methods

* up

* up

* feat(blob): presigned HEAD & DELETE URLs (#1064)

* feat(blob): presigned DELETE URLs

Wires the `'delete'` DelegationOperation (added in #1061, since rebased
out of elliot/presigned-urls) through `presignUrl`. A token issued with
`operations: ['delete']` can now mint a presigned `DELETE /?pathname=…`
against the control-plane API.

- `PresignDeleteUrlOptions` accepts `pathname`, optional `validUntil`,
  optional `ifMatch`. Upload-only fields are rejected at the type level.
- `presign()` gates on the delegation scope including `'delete'`.
- `buildPresignedDeleteUrl()` mirrors the PUT URL shape; the HTTP method
  is the discriminator (canonical signing string carries `operation=delete`).
- `buildPresignCanonicalQueryEntries()` for `delete` emits only
  `validUntil` (when below the delegation ceiling) and `ifMatch`.
- E2E test route + delete button on the presigned-upload demo page.

Based on `elliot/presigned-urls`, not `main`.

* feat(blob): presigned HEAD URLs (#1065)

`HEAD` mirrors `GET` against the blob object host
(`<storeId>.<access>.blob.vercel-storage.com/<pathname>`); the URL shape
is identical and the HTTP method is the discriminator. `operation=head`
goes into the canonical signing string so a GET-signed URL cannot be
replayed as a HEAD (and vice versa).

- `'head'` added to `DelegationOperation`.
- `PresignHeadUrlOptions` — same shape as `PresignGetUrlOptions`
  (`pathname`, optional `validUntil`).
- `presign()` gates on the delegation scope including `'head'`.
- `presignUrl()` reuses `buildPresignedGetUrl()` for `operation: 'head'`.
- `buildPresignCanonicalQueryEntries()` for `head` emits only
  `validUntil` (when below the delegation ceiling) — same as `get`.
- E2E test route + HEAD button on the presigned-upload demo page.

Stacked on `falcoagustin/presigned-delete-impl` (#1064).

* environment error

* up

* cleanup

---------

Co-authored-by: Agustin Falco <agusfalco_11@hotmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* up

* rm dead code

* throw on manual token

* up

* up

---------

Co-authored-by: Agustin Falco <agusfalco_11@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants