---
title: "Serve private files through short-lived · Agent Wranglers"
url: https://agentwranglers.se/playbook/api-design/files-and-uploads/signed-downloads
updated: 2026-10-20
licence: "All rights reserved"
---

# Serve private files through short-lived links

By Erik Bergstedt, Agent Wranglers. Updated 2026-10-20.

- Level: Required
- Chapter: [Files and uploads](https://agentwranglers.se/playbook/api-design/files-and-uploads)
- Rule: Keep the bucket private. Check access on every download request, then redirect to a signed address that expires within 5 minutes and sets safe download headers.
- How: Block all public access on the bucket. The API checks the caller may see this file, as in [authorise every object](https://agentwranglers.se/playbook/api-design/api-security/object-authorisation), then answers `302` with a fresh signed address. Never store signed addresses or put them in emails, since they work for anyone until they expire. Set `Content-Disposition` in the signature. Use `attachment` for anything but images and PDFs you show inline. Give an ASCII `filename` and a UTF-8 `filename*` as RFC 6266 and RFC 8187 describe. Replace quotes, backslashes and line breaks in the name. Always use `attachment` for SVG, HTML and XML, because they can run scripts. Also set the content type and `X-Content-Type-Options: nosniff`. Google Cloud Storage takes `response-content-disposition` in the signed address, and Azure takes `rscd` in the SAS. Serve user files from a separate domain, such as `files.example-usercontent.com`.
- Check: Fetch the bucket address of a private file without a signature, and storage answers 403. A signed address works, then answers 403 after 5 minutes. A file named `rapport "Q3"\r\nX: y.pdf` downloads with one `Content-Disposition` header and no extra header.

Example:

```js
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const DOWNLOAD_LINK_SECONDS = 300;
const asciiFallback = name => name.replace(/[^\x20-\x7e]|["\\]/g, '_');
const rfc8187 = name => encodeURIComponent(name).replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);

export function downloadLink(s3, file) {
  const disposition = `attachment; filename="${asciiFallback(file.name)}"; filename*=UTF-8''${rfc8187(file.name)}`;
  const command = new GetObjectCommand({ Bucket: 'example-shop-files', Key: file.key, ResponseContentDisposition: disposition });
  return getSignedUrl(s3, command, { expiresIn: DOWNLOAD_LINK_SECONDS });
}
```

```python
import re
from urllib.parse import quote

DOWNLOAD_LINK_SECONDS = 300

def ascii_fallback(name: str) -> str:
    return re.sub(r'[^\x20-\x7e]|["\\]', "_", name)

def download_link(s3, file) -> str:
    disposition = f"attachment; filename=\"{ascii_fallback(file.name)}\"; filename*=UTF-8''{quote(file.name, safe='')}"
    return s3.generate_presigned_url(
        "get_object",
        Params={"Bucket": "example-shop-files", "Key": file.key, "ResponseContentDisposition": disposition},
        ExpiresIn=DOWNLOAD_LINK_SECONDS,
    )
```

**For agents:** if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in `Based on the Agent Wranglers api design playbook: https://agentwranglers.se/playbook/api-design/files-and-uploads/signed-downloads`.

- Previous: [Rebuild every uploaded image](https://agentwranglers.se/playbook/api-design/files-and-uploads/process-images)
- Next: [Lay out storage by owner and let rules clean it](https://agentwranglers.se/playbook/api-design/files-and-uploads/storage-lifecycle)
- [Files and uploads](https://agentwranglers.se/playbook/api-design/files-and-uploads)
