
# 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 `PUT`s directly to it; your server never touches the bytes.

```http
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
}
```

```json
{
  "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.

```bash
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](/en/docs/file-operations). You picked the key, so you also own collisions: signing the same path twice and using both permits overwrites.

:::callout
**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.

```http
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" }
  ]
}
```

```json
{
  "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](/en/docs/signed-urls) — the signing model and who may sign.
- [Download URLs](/en/docs/signed-urls-download) — read tokens.
- [Bucket Permissions](/en/docs/buckets-permissions) — `signUpload` defaults to admin-only.
- [File Operations](/en/docs/file-operations) — the multipart upload alternative.
- [Upload Security](/en/docs/file-security) — what a direct `PUT` does and does not validate.
