
# Download URLs

A download signed URL grants read access to exactly one stored path until a moment you choose. Mint it with a session; use it without one.

```http
POST /api/buckets/documents/sign
Content-Type: application/json
Cookie: <session>

{
  "path": "9f3c1e2a-7b44-4d10-9e21-8a6f0c1d2e3b-contract.pdf",
  "expiresIn": 3600
}
```

```json
{
  "success": true,
  "signedUrl": "https://app.example.com/api/buckets/documents/signed?path=9f3c1e2a-…-contract.pdf&op=download&expires=1775386800000&token=4b1f…",
  "expiresAt": "2026-04-05T11:00:00Z",
  "operation": "download"
}
```

`operation` defaults to `download`, so the field is omitted above. Hand `signedUrl` to a browser, an email template, or a PDF renderer — it needs nothing else.

## Request Body

| Field       | Type    | Required | Default    | Notes                                                                      |
| ----------- | ------- | -------- | ---------- | -------------------------------------------------------------------------- |
| `path`      | string  | Yes      | —          | The storage key, exactly as the upload returned it.                        |
| `expiresIn` | integer | No       | `3600`     | Lifetime in seconds. Between `60` and `604800` (7 days).                   |
| `operation` | string  | No       | `download` | `download` or `upload`. See [Upload Signing](/en/docs/signed-urls-upload). |

## Outcomes

| Situation                                      | Result                                             |
| ---------------------------------------------- | -------------------------------------------------- |
| Valid request                                  | `200` with `signedUrl` and `expiresAt`.            |
| `expiresIn` below 60 or above 604800           | `400`                                              |
| `path` missing or empty                        | `400`                                              |
| No file at `path`                              | `404` — a download token must reference real bytes |
| No session                                     | `401`                                              |
| Session lacking the bucket's `sign` permission | `404`                                              |
| Unknown bucket                                 | `404`                                              |

Note the existence check. Unlike upload signing, download signing refuses to mint a token for a path that holds nothing, so a signed URL you are holding is a URL that resolved at least once.

## Using and Expiring the URL

The URL resolves against `GET /api/buckets/{bucket}/signed`. Three things can go wrong at that point, and all three answer `403`:

| At use time                                    | Result                     |
| ---------------------------------------------- | -------------------------- |
| Within the window, signature intact            | `200` with the bytes.      |
| Past `expiresAt`                               | `403`                      |
| Any query parameter altered — path, expiry, op | `403` (signature mismatch) |
| An upload token used for a `GET`               | `403` (operation mismatch) |
| Token valid but the file was deleted meanwhile | `404`                      |

Tokens are compared in constant time, so a malformed or near-miss token leaks nothing through response timing. The `403` for expiry and the `403` for tampering are deliberately the same answer.

:::callout
**Choose the shortest window that works.** A signed URL cannot be revoked individually — once minted it is valid until `expiresAt`, whatever happens to the user's session, role or account in the meantime. Sixty seconds for a redirect, an hour for an email link, seven days only for something genuinely long-lived. The only global revocation is rotating `AUTH_SECRET`, which invalidates every outstanding URL at once.
:::

## Transforms on a Signed URL

An image signed URL accepts [transform parameters](/en/docs/image-transforms) appended to it. The token covers `path`, `op` and `expires` — not the transform query — so one signed URL serves any size:

```text
<signedUrl>&width=200&fit=cover
```

Raster images arrive with `Content-Disposition: inline` on this route, so they render directly in an `<img>`. SVG does not: it is forced to `attachment` because an inline SVG on your origin can execute script. See [Upload Security](/en/docs/file-security).

## Related Pages

- [Signed URLs](/en/docs/signed-urls) — why signing exists and who may sign.
- [Upload & Batch Signing](/en/docs/signed-urls-upload) — write tokens and bulk minting.
- [Attachment Fields & Storage](/en/docs/attachment-storage) — records that arrive pre-signed.
- [Resize & Crop](/en/docs/image-transforms) — the parameters you can append.
- [File Operations](/en/docs/file-operations) — session-based download.
