Private logistics exports need two contracts, not one: the signed link must authorize the object, and the object response must describe how the recipient should receive it. If a PDF opens in a browser when it should download, or a CSV arrives with an internal key as its filename, signing is rarely the interesting failure. The usual fault is that filename and response metadata were left until delivery time.
The signature is not the filename.
Short answer: write the intended Content-Type and Content-Disposition with each export, verify those values before issuing a short-lived signed GET, and treat the object key as an internal identifier rather than a customer filename.
That decision fits a logistics workflow where a dispatcher uploads a private proof-of-delivery document and a customer later downloads an export link. The application authenticates the customer, checks authorization, and creates the link. Storage owns the bytes and their delivery metadata. Those boundaries matter when the same file is reached from a dashboard, an email, and an operations console.
How should object storage make a presigned PDF or CSV download keep its filename?
Start with invariants. The export must be private; the link must expire; the response must carry the media type selected by the producer; and the disposition must say attachment when the product promise is “download this file.” The filename needs an explicit encoding policy, especially for spaces, non-ASCII names, and characters that are meaningful to a URL or header parser.
The object key is a storage concern. tenant-17/job-8841/output-2026-08-11 is a reasonable key, but it is a poor filename. Keeping the two concepts separate prevents a refactor of key naming from changing what a customer sees in their downloads folder.
There is a small but important distinction between authorization and presentation. A valid signature proves that the request may retrieve a particular object for a limited period. It does not, by itself, prove that the object has the right content type or a useful download name. A browser may render a PDF inline when the response allows that behavior, while a CSV may be displayed as text or opened by a spreadsheet association. The application has to make its intended disposition explicit.
The architecture decision record
The selected design writes delivery metadata at the export boundary, then verifies it at the release boundary. The uploader creates an immutable export record containing the object key, media type, customer filename, owner, and expiry policy. A storage write persists the bytes and metadata together where the interface permits it. A metadata check follows. Only then does the application generate the signed link.
The failure boundary is deliberately boring: if the check does not match the export record, the link is not issued. No browser-specific repair is hidden in a redirect, and no downloader has to infer a type from a suffix after the fact.
| Option | Where delivery behavior is decided | Appropriate use | Cost or risk |
|---|---|---|---|
| Metadata at write time | The object upload or metadata update | One export has one canonical customer filename | Requires the producer to validate before release |
| Response override at read time | The signed response request | One immutable object needs different names for different recipients | Every read path must preserve authorization and header rules |
| Application proxy | An authenticated application response | Extra auditing, transformation, or policy is required | Adds bandwidth, latency, and another failure boundary |
For ordinary exports, the first option is the clearest contract. The second is valid when per-recipient naming is a genuine requirement, such as a shared report whose filename contains each recipient’s organization. The proxy is justified when storage delivery cannot express the policy, not because a browser is inconvenient.
This is where operational details become part of the architecture. Store a content hash or export version in the database, log the authorization decision without logging the signed URL, and make the release check observable. A failed metadata check should identify the export ID and expected fields, while credentials and query-string signatures stay out of logs. Lifecycle rules are also a separate concern: an object expiry policy can remove old exports, but it should not be confused with the much shorter lifetime of a download link. The S3 lifecycle documentation is useful here because retention automation and request authorization solve different problems.
A Python release check for PDF and CSV exports
The following generic client shows the critical path without binding the article to a particular storage vendor. The storage adapter is responsible for implementing head_object and presign_get; the policy code remains the same if that adapter later targets a different object-store API.
from dataclasses import dataclass from datetime import datetime, timezone from typing import Protocol class PrivateStore(Protocol): def head_object(self, key: str) -> dict[str, str]: ... def presign_get(self, key: str, expires_in: int) -> str: ... @dataclass(frozen=True) class Export: key: str filename: str content_type: str expires_at: datetime def issue_download(store: PrivateStore, export: Export) -> str: now = datetime.now(timezone.utc) if export.expires_at <= now: raise ValueError("export is expired") metadata = store.head_object(export.key) expected_disposition = f'attachment; filename="{export.filename}"' if metadata.get("Content-Type") != export.content_type: raise ValueError("stored content type does not match the export record") if metadata.get("Content-Disposition") != expected_disposition: raise ValueError("stored content disposition does not match the export record") seconds = max(1, int((export.expires_at - now).total_seconds())) return store.presign_get(export.key, expires_in=seconds) pdf = Export( key="tenant-17/job-8841/output-2026-08-11", filename="proof-of-delivery-8841.pdf", content_type="application/pdf", expires_at=datetime(2026, 8, 11, 14, 0, tzinfo=timezone.utc), ) csv = Export( key="tenant-17/job-8841/manifest-2026-08-11", filename="shipment-manifest-8841.csv", content_type="text/csv; charset=utf-8", expires_at=datetime(2026, 8, 11, 14, 0, tzinfo=timezone.utc), ) The adapter must map these abstract calls to the storage service’s documented operations. Do not copy a route from a different provider, and do not silently turn a metadata mismatch into a fresh upload. If the producer and the object disagree, stop the release and repair the producer’s contract in a controlled job.
The example keeps the header comparison strict because a download policy should be testable. In production, filename encoding deserves its own tests rather than a naïve string interpolation rule. Include names with spaces, Unicode, quotes, and a very long basename. Use the standards-defined filename* form when the target clients require UTF-8 names, and test the exact wire response in the browsers and embedded webviews that the logistics product supports. I'm not sure a single browser test can establish compatibility across every customer’s device; your mileage may vary, so document the tested matrix instead of promising universal behavior.
Which failure modes should the test suite catch?
The common failures are upstream of the link:
- The upload uses a generic binary media type, so the browser receives no reliable hint about the artifact.
- A producer stores the customer filename only in a database, while another delivery path falls back to the object key.
- A metadata update runs asynchronously and the link is issued before the update is visible to the read path.
- A signed URL is logged, copied into an analytics event, or included in a referrer, extending the practical exposure of a private export.
- An export job retries with a new object key but leaves the old database record pointing at the previous bytes.
I have seen the most expensive class of mistake in review: a happy-path test creates report.pdf, but the actual job creates a key with no suffix and never checks the response headers. The file bytes are perfect. The customer experience is wrong. That is a contract failure, not a PDF problem.
Test at three boundaries: the metadata persisted after creation, the authorization and expiry decision before presigning, and the actual response observed by a supported client. Check that unauthorized users cannot obtain a link, that expired exports are rejected, and that a link’s lifetime is no longer than the export policy. Also test retries and concurrent export updates; a filename fix that races with a replacement file is still a data-layer bug.
When is this design the wrong choice?
Writing one canonical filename is not suitable when the same bytes must be rendered under a recipient-specific name, transformed on demand, or subjected to a policy that depends on request context. Use read-time response overrides or an authenticated application proxy in those cases, and accept the added testing and observability work.
It is also the wrong foundation for a retention-sensitive archive if the selected object store cannot provide the required versioning, legal hold, or write-once controls. An expiring link is not an archive policy. Likewise, lifecycle cleanup should not be configured with a retention period shorter than an investigation or chargeback process merely because old exports are inconvenient to list.
The limitation is important: this rule keeps delivery simple, but it does not solve retention, audit, transformation, or client compatibility. Put stable artifact facts beside the bytes, put changing authorization decisions in the application, and refuse to issue a link when the two records disagree. Choose a proxy or a storage system with stronger archive controls when those requirements govern the design.