What never reaches the browser
UPSTASH_BLOB_TOKEN, in any form.- The key that signs completion tokens. It never leaves your process.
- The temporary S3 credentials. They sign your server’s own requests and never appear in a URL.
- Any ability to list, read, overwrite or delete outside the one object a presigned URL names.
- Anything the context or the before-upload callback computed, except what you explicitly return as metadata (visible on the object) or state (visible in the completion token).
The bucket token
UPSTASH_BLOB_TOKEN packs three things the SDK reads locally, with no network call:
- The bucket id, which names the bucket inside your account.
- The public DNS label, from which the client’s public URL call builds a URL. Whether the bucket serves one at all is not in the token: the SDK learns that from the credentials response.
- A secret key, used only to sign completion tokens inside your own process. It is never sent anywhere, not to Upstash and not to storage.
The token is a bearer secret. Anything holding it can mint credentials for the whole bucket. Keep it server side; see Connecting.
Minting temporary credentials
The token is not an S3 credential. To touch storage, the SDK exchanges it with Upstash for a short-lived one. That exchange also tells the SDK where the bucket lives, when the credential expires, and whether the bucket is public or private. Credentials are cached per token rather than perBucket instance, so constructing a bucket inside a request handler is free. Concurrent callers share one in-flight mint, and a credential is refreshed shortly before it expires.
A failed mint surfaces as unauthorized (the token was rejected), rate_limited, not_ready or mint_backoff. See Credential errors.
Who signs a URL
Upstash does, never your server. A temporary credential cannot sign a URL safely: storage needs its session token on every request, so the URL would carry the token, and the credential’s secret can be derived from it. Anyone holding such a URL would hold the credential, and with it every object in the bucket until it expires. So signed read URLs, signed upload URLs and the upload handler send Upstash the path, the method, the headers to pin and the lifetime, authenticated with the bucket token. Upstash checks them against the token’s own bucket and signs with a key no URL reveals. The URL does the one operation it names, on the one object, and nothing else, as often as it is used until it expires.Signing a request
Signing is AWS Signature Version 4. Requests your server makes to storage carry the signature in anAuthorization header. Every URL handed to a browser, and every signed read or upload URL, carries Upstash’s signature on the query string instead.
Signed headers
A presigned URL can pin headers, and the client then has to send them back exactly. Change a value, or omit a header the URL declared, and storage answers 403. This is enforced by the signature itself, not by the SDK. Query parameters work the same way. The download filename on a signed read URL rides inside the signature, so a link whose filename was edited afterwards is refused.Path encoding
Paths are percent-encoded per segment, so slashes stay structural and everything else survives. A path containing a. or .. segment is refused rather than normalized. Your server’s credential authorizes the whole bucket, and the URL parser resolves .. before signing, so a traversing key would sign a request against a different object than the one your code named.
A signed URL also cannot name a path with an empty segment (dir/, /a, a//b) or a control character. Asking for one is invalid_input, and an upload is refused at begin, before its multipart upload is created.
How long a presigned URL lives
The direct upload handshake


The browser sees per-object presigned URLs, the headers those URLs pin, and a completion token. Nothing it holds can list the bucket, read another object, or write to a path your before-upload callback did not choose.
The completion token
The completion token carries an upload’s identity between phases, so your route keeps no server state. It is signed with a key derived from the bucket token, which never leaves your process. It pins everything the browser must not be able to change: the path the before-upload callback chose, the declared size and type, the headers signed into the upload, the bucket, and the route. It also carries the upload id your completion callback sees, and whatever the before-upload callback returned as state. It expires after seven days.The token is signed, not encrypted. Anyone can open devtools and read the payload, including
state. Put a row id there, never a secret, a signed URL, or an internal flag.forbidden, not a 500. A token minted at one route is not spendable at another, so a 2 MB avatar route’s token cannot be spent at a 2 GB video route. Two handlers on one bucket that mount the same route names need an endpoint to tell them apart.
Changing a route’s constraints invalidates completion tokens issued under the old shape, since the grant no longer describes what the route enforces.
Pinned headers, and why the browser cannot forge metadata
For a file under the multipart threshold, the browser writes the object itself with a single PUT. Everything the object should carry is decided on your server and pinned into that URL’s signature:content-type, from the file the browser declaredcache-control, resolved from the route’s or bucket’s cache option and the bucket visibility- every
x-amz-meta-*derived from the metadata your before-upload callback returned content-length, the exact declared sizex-amz-meta-upstash-upload, the marker
owner your app reads back in the completion callback would be the client’s to write.
For a multipart upload the same headers are set by your server when it creates the upload, and the object inherits them at completion. Part URLs pin only the part’s length.
The upstash-upload marker
On a single PUT, the SDK writes a random id as x-amz-meta-upstash-upload and signs it into the URL. The browser cannot set it or change it, and an upstash-upload metadata key from your own before-upload callback is refused as reserved.
It answers one question: did the bytes at this path come from this upload? A multipart upload knows by construction, since the object does not exist until it is completed. A single PUT stores the object the moment the last byte lands, so the object at that path could be from a stale upload, a concurrent upload, or something that was there all along.
Completing a single-PUT upload requires a marker match. No match is not_found. A cancel uses the same check, which stops it from deleting someone else’s file at the same path.
The marker is stripped from the record handed to the completion and error callbacks, but stays on the stored object. A match proves “same upload”, never “a callback accepted it”. That is why the SDK cannot find an abandoned single-PUT object on its own; see Abandoned uploads.
Retries and 403
A 403 from storage is ambiguous: an expired presigned URL and a tampered request produce the same status. The browser treats a 403 as an expired signature first and asks your route for fresh URLs. A 403 on a URL that was just signed is a realsignature_mismatch. See Retries.
Your server reads the response body instead. It re-mints once per request when the body says the credential expired. Any other 403 surfaces as signature_mismatch, usually meaning the body length or type differs from what was signed.
What the browser stores
Only the completion token, inlocalStorage, keyed by the route and the file. Nothing about what landed is stored, since your server asks storage for that. See Resuming after a reload.
Signed URLs you make yourself
Content-Disposition header, encoded so a Unicode name arrives intact and a name containing a quote or a newline cannot inject a second header. A content type override is validated as a media type for the same reason.
A signed upload URL signs every header it returns: the content type, cache control, your metadata, the length when you pass a size, and the conditional when you refuse overwrites. Send the returned headers verbatim. Anything changed, dropped or added is a 403.
Full options are on Reading and Writing. Method signatures: TypeScript, Python, Go. For an existing S3 client, see Connecting.