Upload & Batch Signing
Two problems that the single download token does not solve: getting large files into storage without routing every byte through the application server, and getting many URLs at once without one round trip each.
Upload URLs
An upload signed URL is a one-shot write permit for one path. The browser PUTs directly to it; your server never touches the bytes.
POST /api/buckets/uploads/sign
Content-Type: application/json
Cookie: <session>
{
"path": "uploads/user-123/profile-photo.jpg",
"operation": "upload",
"expiresIn": 600,
"contentType": "image/jpeg",
"maxSize": 5242880
}
{
"success": true,
"signedUrl": "https://app.example.com/api/buckets/uploads/signed?path=uploads%2Fuser-123%2Fprofile-photo.jpg&op=upload&expires=1775383800000&token=8c2d…&ct=image%2Fjpeg&max=5242880",
"expiresAt": "2026-04-05T10:10:00Z",
"operation": "upload"
}
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path |
string | Yes | — | The exact key to write. You choose it. |
operation |
string | Yes | — | Must be upload; the default is download. |
expiresIn |
integer | No | 3600 |
60 to 604800 seconds. |
contentType |
string | No | any | Enforced on the PUT. |
maxSize |
integer | No | 10485760 (10 MB) |
Byte ceiling, enforced on the PUT. |
Unlike a download token, an upload token does not require the file to exist — that is the point. permissions.signUpload gates who may mint one, and it defaults to admin-only.
The Constraints Are Signed
contentType and maxSize appear in the query string as ct and max, and both are folded into the HMAC payload. Editing either invalidates the token, so a client cannot widen its own permit by rewriting the URL it was given.
curl -X PUT "$SIGNED_UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
At PUT time |
Result |
|---|---|
| Within window, type and size honoured | 200 with { "success": true, "path": … } |
Content-Type differs from the bound ct |
400 |
Body larger than the bound max |
413 |
| Past expiry, or any parameter altered | 403 |
A download token used for a PUT |
403 (operation mismatch) |
The stored file lands at exactly the path you signed — no UUID prefix is added, unlike a multipart upload. You picked the key, so you also own collisions: signing the same path twice and using both permits overwrites.
A write permit is a bigger grant than a read one — size it accordingly. Anyone holding the URL can write those bytes until it expires. Ten minutes is generous for a form submission; an hour is already a long time for a permit nobody can revoke. Always set contentType and maxSize explicitly rather than accepting "any type, 10 MB".
Batch Signing
One request, up to 100 URLs. The obvious use is a gallery page where every thumbnail is private.
POST /api/buckets/photos/sign/batch
Content-Type: application/json
Cookie: <session>
{
"files": [
{ "path": "photo-1.jpg", "expiresIn": 3600 },
{ "path": "photo-2.jpg", "expiresIn": 3600 },
{ "path": "uploads/new.jpg", "expiresIn": 600, "operation": "upload" }
]
}
{
"results": [
{ "path": "photo-1.jpg", "signedUrl": "https://…", "expiresAt": "2026-04-05T11:00:00Z" },
{ "path": "photo-2.jpg", "error": "not_found" },
{ "path": "uploads/new.jpg", "signedUrl": "https://…", "expiresAt": "2026-04-05T10:10:00Z" }
]
}
Each entry carries its own expiresIn and its own operation. Download entries whose file is missing come back with error: "not_found" instead of failing the batch — a gallery with one dead reference still renders the other ninety-nine.
Two things do fail the whole request: more than 100 entries, and any single out-of-range expiresIn. Both answer 400. The asymmetry is intentional — a missing file is a data condition you can render around, while a bad expiry is a bug in the caller.
Related Pages
- Signed URLs — the signing model and who may sign.
- Download URLs — read tokens.
- Bucket Permissions —
signUploaddefaults to admin-only. - File Operations — the multipart upload alternative.
- Upload Security — what a direct
PUTdoes and does not validate.
Last updated July 27, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta — contributions and corrections are welcome.