Short answer: treat Content-Type and Content-Disposition as part of the private export contract, persist them with the object, and create the presigned download only after verifying that metadata. A browser opening a PDF or CSV in a tab is usually a response-header problem, not a reason to make the object public.
That distinction matters in a media system. A tenant may export a CSV of media activity or a PDF review packet, while the storage key should reveal neither the tenant's preferred filename nor its internal account number. The key is an isolation boundary; the download filename is presentation. Mixing those jobs creates confusing bugs and occasionally leaks information.
What should a private object storage export guarantee?
I would record four invariants in the architecture decision record:
| Invariant | Implementation responsibility | Failure it prevents |
|---|---|---|
| The object is private | Authorization service issues a short-lived presigned link | A copied URL becomes an uncontrolled public file address |
| The bytes have a declared type | Exporter selects application/pdf or text/csv from the generated format | A client guesses from an internal key or a generic binary type |
| The response asks for a download | Stored metadata includes Content-Disposition: attachment and a safe filename | The browser opens a PDF or CSV inline |
| Tenant access is checked before signing | The signing service resolves tenant and object ownership from a server-side record | A valid signature is mistaken for a valid business authorization |
The last invariant is easy to omit because a presigned URL feels like the authorization step. It is only the delivery credential. It should be minted after the application has established that the requesting user can access that export, and it should carry an expiry appropriate to the handoff. A link sent to a media client is not a replacement for an export record with an owner, format, and status.
The browser is the final observer of the system, so the test needs to reach it. An HTTP client that receives 200 and the expected bytes has proved transport. It has not proved the saved filename or whether a PDF opens inline. Small detail, large distinction.
How do presigned download headers fix the wrong filename and content type?
Start with the generated file, not with the URL. If an export worker produces a PDF, set the media type from that fact. If it produces a CSV, set the CSV type. Do not infer a content type from an untrusted display name, and do not let a storage key such as tenant-1842/job-771 become the customer-facing name.
Then construct a content disposition that explicitly requests an attachment. A practical filename has two parts: an ASCII fallback for older or limited clients, and an encoded UTF-8 value for names that contain spaces or non-ASCII characters. The fallback should be a basename, never a user-supplied path.
Here is the policy layer I would put in front of any storage adapter. It does not know which object-storage implementation signs URLs; that separation is deliberate. The policy can be unit-tested without pretending that a signed link is a browser test.
from __future__ import annotations import re import unicodedata from pathlib import PurePath from urllib.parse import quote MEDIA_TYPES = { ".csv": "text/csv", ".pdf": "application/pdf", } def ascii_fallback(filename: str) -> str: normalized = unicodedata.normalize("NFKD", filename) plain = normalized.encode("ascii", "ignore").decode("ascii") plain = re.sub(r"[^A-Za-z0-9._ -]", "_", plain).strip(" .") return plain or "download" def export_headers(filename: str) -> dict[str, str]: if not filename or PurePath(filename).name != filename: raise ValueError("filename must be a non-empty basename") suffix = PurePath(filename).suffix.lower() if suffix not in MEDIA_TYPES: raise ValueError("only PDF and CSV exports are supported") fallback = ascii_fallback(filename).replace('"', "_") encoded = quote(filename, safe="") return { "Content-Type": MEDIA_TYPES[suffix], "Content-Disposition": ( f'attachment; filename="{fallback}"; ' f"filename*=UTF-8''{encoded}" ), } This fixes the policy inputs, not the persisted object by magic. The adapter must apply the returned values while uploading or through its supported metadata update operation, then read the object's metadata before asking for a fresh presigned link. A link issued before the correction belongs to the old delivery attempt; discard it and create another one.
There is a second subtlety: the extension and declared type must agree with the bytes. Renaming a CSV to .pdf makes the filename look right while leaving the client with the wrong expectation. A useful test fixture contains a real PDF, a real CSV, a name with spaces, a Unicode name, and a long name. I'm not sure which filename limit applies across every browser, proxy, and receiving filesystem, so I would test the actual delivery path instead of publishing a made-up universal limit.
Where does tenant isolation fail in the download path?
The most dangerous design is one that checks the tenant once, then passes an object key around as if the key itself were proof. Keys are identifiers, not authorization statements. A download request should resolve an export record such as (tenant_id, export_id, object_key, format, display_name) and compare the authenticated tenant before signing.
A clean critical path looks like this:
- The export job writes bytes under an opaque key owned by one tenant.
- The application stores the format and intended display name beside the export record.
- The download request checks the authenticated tenant against that record.
- The service derives headers from the recorded format and sanitized basename.
- The storage adapter verifies the object's metadata and creates a short-lived presigned link.
- The browser test checks disposition, filename, media type, and bytes.
Do not let a client submit a replacement Content-Disposition and treat it as trusted. A client may request a label, but the server owns the final basename and must reject path fragments. Keep the object key opaque as well; an internal key can still disclose sequence numbers or account relationships through logs, referrers, or support screenshots.
The failure modes deserve separate alerts. A PDF displayed inline points at disposition. A downloaded file named after the object key points at filename handling. A CSV downloaded with a PDF association points at the declared type or the generated bytes. A cross-tenant result points at the authorization lookup, regardless of whether the signature itself was valid.
One incident can contain more than one failure. That is why I would log a redacted export identifier, tenant identifier, selected format, metadata values, signing timestamp, expiry, and a result category. I would not log the full presigned URL: query parameters are credentials, even when they are short-lived.
Which design trade-offs belong in the decision record?
There are three reasonable places to enforce download behavior, and they do not provide the same guarantees.
| Choice | Strength | Cost or boundary |
|---|---|---|
| Persist metadata on the object | Every delivery starts from a durable, inspectable contract | A metadata correction may require a supported rewrite or update, and a new link must be issued |
| Add response overrides when signing | Useful when one object needs different presentation names | The signing adapter must support the override, and stored metadata can still be wrong for other readers |
| Proxy the download through the application | Centralizes tenant checks and response headers | The application owns bandwidth, range requests, timeouts, and retry behavior |
For private media exports, I prefer durable metadata plus an application authorization check. A response override can be a valid exception for a one-off label, but it should not hide a bad upload contract. A proxy is a valid choice when policy inspection, audit requirements, or content transformation outweigh direct delivery efficiency.
The catch is that this pattern is not suitable for every file-delivery product. If the requirement is a permanent public asset, static hosting, or anonymous sharing, a signed-only export path is the wrong abstraction; use a system whose access model explicitly supports that requirement. If the application needs object versioning, immutable retention, conditional writes, or sub-day automatic deletion, those controls must be verified separately rather than assumed from the presence of presigned links. The AWS lifecycle documentation is useful for checking expiry assumptions, and a worker platform may be appropriate for an application-controlled delivery layer, but neither source changes the tenant authorization invariant.
How should the fix be tested before deployment?
Unit tests should cover header derivation and rejection of path-like names. Integration tests should prove that metadata survives the storage adapter and that a new signature is generated after a correction. The browser test is the one teams skip, usually because the API test is green.
I would make the browser matrix small and intentional: one PDF and one CSV, each with a plain name, spaces, and Unicode. Assert the suggested filename, whether the browser downloads rather than opens inline, the response media type, and a hash or parser-level check of the bytes. Repeat after link expiry so the client exercises its authorized refresh flow instead of silently falling back to a public URL.
Deployment should also preserve the ordering. Rolling out a new filename policy before the worker and adapter agree on format metadata creates mixed exports, while signing before the authorization record is committed creates a race between visibility and access. A feature flag can limit the new path to a test tenant, but the invariant itself should remain the same.
The decision is therefore straightforward: keep tenant authorization in the application, keep bytes and metadata aligned at write time, make attachment disposition explicit, and verify the actual browser behavior. Fixing a filename in frontend code is cosmetic if the response still says inline, and making an object public is an isolation regression disguised as a download fix.
