
# Signed URLs

Signed URLs grant temporary, scoped access to private files without exposing storage credentials or making the file permanently public. A signed URL embeds an HMAC-SHA256 token (signed with `AUTH_SECRET`) that encodes the path, operation, and expiry. Tampering with any part invalidates the token.

Signed URLs work across all three storage backends. For S3 backends, Sovrium delegates to native S3 presigning; for [local and bytea backends](/en/docs/buckets-overview#storage-backends), it generates and validates signatures internally — no AWS SDK required at the application layer.

## Public vs Private Access

By default, files are private and require either authentication or a signed URL. Operators can carve out always-public path prefixes.

| Variable                 | Default   | Purpose                                                                     |
| ------------------------ | --------- | --------------------------------------------------------------------------- |
| `STORAGE_DEFAULT_ACCESS` | `private` | Default access level for new files (`private` or `public`).                 |
| `STORAGE_PUBLIC_PATHS`   | (none)    | Comma-separated path prefixes served without authentication or signed URLs. |

```bash
STORAGE_DEFAULT_ACCESS=private
STORAGE_PUBLIC_PATHS=assets/,avatars/,logos/
```

| Behavior                                       | Result                                         |
| ---------------------------------------------- | ---------------------------------------------- |
| File under a `STORAGE_PUBLIC_PATHS` prefix     | Served without authentication or a signed URL. |
| File outside public paths, no valid signed URL | `403 Forbidden`.                               |
| `STORAGE_DEFAULT_ACCESS=public`                | All files accessible without signed URLs.      |

Public-path configuration is validated on startup — no trailing wildcards, no overlapping prefixes. A bucket marked `public: true` in the schema is the per-bucket equivalent (see [Buckets Overview](/en/docs/buckets-overview#public-vs-private)).

## Download Signed URLs

Request a time-limited download URL for a private file.

```http
POST /api/buckets/{bucket}/sign
Content-Type: application/json
Authorization: Bearer <token>

{
  "path": "documents/contract.pdf",
  "expiresIn": 3600,
  "operation": "download"
}
```

```json
{
  "signedUrl": "https://app.example.com/api/buckets/documents/files/documents/contract.pdf?token=eyJhbGciOiJIUzI1NiJ9...",
  "path": "documents/contract.pdf",
  "expiresAt": "2026-04-05T11:00:00Z"
}
```

| Parameter   | Type    | Required | Default    | Description                                      |
| ----------- | ------- | -------- | ---------- | ------------------------------------------------ |
| `path`      | string  | Yes      | —          | File path relative to the storage root.          |
| `expiresIn` | integer | No       | `3600`     | Expiry in seconds. Range: 60 to 604800 (7 days). |
| `operation` | string  | No       | `download` | `download` or `upload`.                          |

| Behavior                        | Result                                      |
| ------------------------------- | ------------------------------------------- |
| Valid path                      | Signed URL plus `expiresAt` timestamp.      |
| Accessing the URL before expiry | File served with no further authentication. |
| Accessing after expiry          | `403 Forbidden`.                            |
| `expiresIn` < 60 or > 604800    | `400 Bad Request`.                          |
| Non-existent path               | `404 Not Found`.                            |
| Tampered token                  | `403 Forbidden`.                            |

## Upload Signed URLs

Issue a write token so a browser can upload directly to storage without proxying bytes through the application server.

```http
POST /api/buckets/{bucket}/sign
Content-Type: application/json
Authorization: Bearer <token>

{
  "path": "uploads/user-123/profile-photo.jpg",
  "expiresIn": 600,
  "operation": "upload",
  "contentType": "image/jpeg",
  "maxSize": 5242880
}
```

```json
{
  "signedUrl": "https://app.example.com/api/buckets/uploads/files/uploads/user-123/profile-photo.jpg?token=eyJhbGciOiJIUzI1NiJ9...",
  "path": "uploads/user-123/profile-photo.jpg",
  "expiresAt": "2026-04-05T10:10:00Z",
  "method": "PUT",
  "headers": { "Content-Type": "image/jpeg" }
}
```

| Parameter     | Type    | Required | Default            | Description                                          |
| ------------- | ------- | -------- | ------------------ | ---------------------------------------------------- |
| `contentType` | string  | No       | —                  | Required MIME type, enforced server-side on the PUT. |
| `maxSize`     | integer | No       | `10485760` (10 MB) | Maximum upload size in bytes.                        |

| Behavior                                 | Result                                            |
| ---------------------------------------- | ------------------------------------------------- |
| Upload via the PUT URL                   | File stored at the specified path.                |
| Upload after expiry                      | `403 Forbidden`.                                  |
| Wrong MIME type (when `contentType` set) | `400 Bad Request`.                                |
| Exceeds `maxSize`                        | `413 Payload Too Large`.                          |
| Reusing an upload token to download      | Rejected — the operation is encoded in the token. |

## Batch Signing

Generate signed URLs for up to 100 files in one request — useful for rendering a page full of private images.

```http
POST /api/buckets/{bucket}/sign/batch
Content-Type: application/json
Authorization: Bearer <token>

{
  "files": [
    { "path": "photos/photo-1.jpg", "expiresIn": 3600 },
    { "path": "photos/photo-2.jpg", "expiresIn": 3600 },
    { "path": "documents/report.pdf", "expiresIn": 7200 }
  ]
}
```

Each file may carry its own `expiresIn`. Non-existent files are returned with an `error: "not_found"` entry (partial success rather than failing the whole batch). Requesting more than 100 files returns `400 Bad Request`.

## Endpoint Summary

| Method | Endpoint                                       | Auth                                    |
| ------ | ---------------------------------------------- | --------------------------------------- |
| `POST` | `/api/buckets/{bucket}/sign`                   | Required (session).                     |
| `POST` | `/api/buckets/{bucket}/sign/batch`             | Required (session).                     |
| `GET`  | `/api/buckets/{bucket}/files/{path}?token=...` | None — the token is the authentication. |
| `PUT`  | `/api/buckets/{bucket}/files/{path}?token=...` | None — the token is the authentication. |

Calling either `sign` endpoint without a valid session returns `401`.

## Access Control

Who may sign is governed by two independent layers, both using the shared `PermissionValueSchema` format (`all` | `authenticated` | role list).

### Per-Bucket Permissions

Set `sign` and `signUpload` on the bucket.

```yaml
buckets:
  - name: documents
    permissions:
      sign: authenticated
      signUpload: [admin, editor]
      download: authenticated
      delete: [admin]

  - name: public-assets
    public: true
    permissions:
      sign: all
      signUpload: [admin]
      download: all
```

| Behavior                                | Result                                    |
| --------------------------------------- | ----------------------------------------- |
| Caller matches `permissions.sign`       | Can generate download signed URLs.        |
| Caller matches `permissions.signUpload` | Can generate upload signed URLs.          |
| Caller does not match                   | `403 Forbidden`.                          |
| Bucket has no `permissions` block       | Only the `admin` role can sign (default). |

### Per-Role Storage Permissions

Roles can independently grant or revoke signing via `storage.sign` / `storage.signUpload`.

```yaml
auth:
  roles:
    - name: member
      permissions:
        storage:
          sign: true
          signUpload: false
    - name: viewer
      permissions:
        storage:
          sign: false
          signUpload: false
```

Admin has full signing rights by default. When no storage permissions are defined anywhere, signing defaults to admin-only.

## Attachment Field Integration

When a table [attachment field](/en/docs/attachment-fields) points at a private file, the record API response automatically embeds a signed download URL (default 1-hour expiry):

```json
{
  "id": 1,
  "name": "Q1 Report",
  "attachment": {
    "path": "documents/q1-report.pdf",
    "filename": "q1-report.pdf",
    "size": 245760,
    "mimeType": "application/pdf",
    "signedUrl": "https://app.example.com/api/buckets/documents/files/documents/q1-report.pdf?token=...",
    "signedUrlExpiresAt": "2026-04-05T11:00:00Z"
  }
}
```

Public files include a direct URL with no token. Image attachments can carry [transform parameters](/en/docs/image-transforms) inside the signed URL.

## Related Pages

- [Buckets Overview](/en/docs/buckets-overview) — buckets, backends, and the `public` toggle.
- [File Operations](/en/docs/file-operations) — direct upload, download, and delete.
- [Image Transforms](/en/docs/image-transforms) — combine transforms with signed URLs.
- [Attachment Fields](/en/docs/attachment-fields) — auto-signing in record responses.
- [Environment Variables](/en/docs/env-vars) — public-path and access-level configuration.
