Skip to main content
View as Markdown

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.

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.

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.

Built with Sovrium