Skip to content

Vercel Blob handleUpload: never trust the pathname from the client

With client uploads the browser names the pathname and the token is bound to it. Check it in onBeforeGenerateToken, or any signed-in user can write anywhere in your store.

Vercel Blob2 min readships at docs/solutions/vercel-blob/never-trust-the-client-pathname.md

Tags: vercel-blob · security · handleUpload · onBeforeGenerateToken · authorization

The standard client-upload example authenticates the user in onBeforeGenerateToken and returns a token. It never looks at pathname.

That is a hole. The browser chose that pathname.

What goes wrong

onBeforeGenerateToken: async (pathname) => {
  const user = await requireUser(); // authenticated, so...
  return { allowedContentTypes: ["image/png"] };
},

A signed-in attacker calls upload("victim-id/avatar.png", evil, ...). They are authenticated, so they get a token for victim-id/avatar.png.

  • With allowOverwrite: true, they replace the victim's file.
  • With the default false, they can still squat any pathname before the victim does, or fill someone else's folder.
  • If your code later trusts "files under <userId>/" as that user's files, they have planted a file in someone else's account.

Why you cannot just rewrite it

handleUpload() signs the token for the pathname the client sent. Your callback can accept or refuse it. It cannot change it. A "sanitised" pathname you compute and return does not change which pathname the token allows.

The fix: the server mints, then verifies

  1. Reserve. The browser posts { filename, contentType, size }. The server authenticates and builds the key from the session: `${user.id}/${folder}/${crypto.randomUUID()}-${safeName}`.

  2. Upload. The browser calls upload(key, file, ...) with that key.

  3. Verify in onBeforeGenerateToken. Refuse unless the pathname:

    • starts with the caller's own id, compared as a full segment (key.split("/")[0] === userId, never startsWith, or alice-2 passes for alice);
    • has the exact shape the server mints (folder, uuid, safe name);
    • ends in the extension for the content type the token will allow.
onBeforeGenerateToken: async (pathname, clientPayload) => {
  const user = await requireUser();
  const { contentType, size } = JSON.parse(clientPayload ?? "{}");
  assertAllowed(contentType, size);
  assertOwnedKey(pathname, user.id, contentType);
  return {
    allowedContentTypes: [contentType],
    maximumSizeInBytes: size,
    allowOverwrite: false,
    addRandomSuffix: false,
    validUntil: Date.now() + 15 * 60 * 1000,
    tokenPayload: JSON.stringify({ userId: user.id }),
  };
},

Treat clientPayload as hostile too

clientPayload is whatever the browser sent. Validate it like a request body. The safe move is to use it only to narrow the token: one content type, the claimed size. If the browser lied, the store rejects the upload.

tokenPayload is different: your server wrote it and it is signed into the token. That is what onUploadCompleted should trust for the owner id.

Also check on the way out

Any route that reads or deletes a key from a request needs the same owner check. Authentication proves who is asking. It does not prove the file is theirs.