TL;DR
Treat a failing file-export link as an export-path problem before treating it as a signing problem: preserve the exact object key, measure signing-host time, choose a TTL that fits the delivery path, and verify the object before a link is issued. That order narrows most 403 signature-mismatch investigations without requiring anyone to reconstruct a canonical request under pressure.
A temporary URL is a compact authorization decision. It cannot prove that the export worker wrote the expected bytes, that a later component will preserve the signed host and path, or that the recipient will click before its expiry. Those are separate contracts, owned by different parts of the system, and an availability objective that names only the endpoint which issued the link misses the user-visible outcome.
Start at the handoff.
The incident lesson: sign the stored name, not a reconstructed filename
Consider a bounded production case: an export job finishes, records a human-readable filename in a database, and hands a notification worker enough fields to generate a download link later. The worker rebuilds the object key from the filename, tenant, and date. It works for ordinary CSV names, then a report containing a space, a plus sign, or non-ASCII text begins returning 403. The signed request is internally consistent, but it addresses a different byte sequence from the key that was written.
That is why the useful invariant is smaller than “the signature is valid.” The issuer must sign the exact, opaque key returned by the write operation. A display name is metadata; it is not an identifier. RFC 3986 distinguishes the characters used as URI syntax from data that must be percent-encoded, and percent-encoding operates on bytes. A service that turns an object key into a form-style query string can turn a space into +; that is not the same path data in a URL path.
Keep both values. Store an immutable object key alongside a separate display filename, record the bucket or namespace that owns it, and pass the key through the queue unchanged. The moment a later job needs to concatenate segments or normalize a filename to find the object again, it has made the download path dependent on presentation logic.
The preventative code is intentionally dull. It makes the identity boundary explicit and asks the storage adapter to sign the key it was given.
package exports import ( "context" "fmt" "time" ) type StoredExport struct { Bucket string ObjectKey string DisplayName string } type ObjectStore interface { Head(context.Context, string, string) error PresignGet(context.Context, string, string, time.Duration) (string, error) } func IssueLink(ctx context.Context, store ObjectStore, file StoredExport, ttl time.Duration) (string, error) { if err := store.Head(ctx, file.Bucket, file.ObjectKey); err != nil { return "", fmt.Errorf("verify export object: %w", err) } return store.PresignGet(ctx, file.Bucket, file.ObjectKey, ttl) } The Head call is not a substitute for an end-to-end test. It is a state check at the handoff boundary, where a missing object can become a job failure with an actionable log field rather than a customer report about a link. The longer path still needs tracing: job ID, object key, signer identity, issuance timestamp, expiry timestamp, and the outcome category observed at the edge. Build the event schema before the next incident, because joining a queue record to a storage audit event is awkward when one system calls the object key, another calls it name, and a third discarded the identifier in favor of a filename. Don't log the full temporary URL; it is a credential for its lifetime. If support needs to identify a specific link, log a non-secret correlation ID at issuance and return it with the export status instead.
Why can an object storage file export download URL return 403 after signing?
Because “403” collapses several different decisions into one client result. A signature mismatch can be caused by a changed host, method, path, query parameter, or header that participates in the signature. An expired link is validly formed but outside its allowed time window. A policy can deny an otherwise well-formed request. A missing key may also be intentionally indistinguishable from denial for a caller that is not allowed to list a container, since revealing absence can disclose inventory.
Start with the evidence that separates those branches. Compare the key stored at write time with the key delivered to the signer as raw bytes or an unambiguous escaped representation. Record the signing timestamp and requested TTL. Confirm that the URL reaches the same authority and path that were signed; redirects, a proxy rewrite, and a “friendly” download hostname are changes, not harmless decoration. Then inspect the authorization policy and the storage service's audit record using an identifier that is safe to correlate.
This sequence matters operationally. Hand-calculating a canonical request may be necessary when the evidence says the request was mutated, but it is a poor first move when an export may never have been committed. The cheapest useful test is often a read of the recorded export state followed by a metadata lookup using the stored key and a controlled principal. Keep the test separate from customer traffic, because the permissions used for diagnosis should not quietly expand the permissions granted to every link recipient.
Browsers add a separate axis. A navigation to a file URL and a script using fetch do not have the same cross-origin behavior. CORS is enforced by the browser, and MDN's guidance makes clear that it depends on the request and the server's response headers. If a direct navigation succeeds while a JavaScript request is blocked, inspect the browser's CORS diagnostics before changing signatures or bucket policy. The storage authorization path may be fine.
Time budgets are part of export capacity planning
Expiry is usually treated as a security knob. It is also a latency budget distributed across queueing, email delivery, a recipient's delay, and clock error. For a short-lived internal transfer, a short TTL may be appropriate. For a report delivered through an inbox, it can be too short even when the signer is correct.
Make the budget visible in the job model. The issuer should calculate an absolute expiry, log it, and ensure the signing hosts use synchronized time. Clock drift turns a configuration decision into a confusing incident: a host that is ahead can issue links which appear to have spent part of their lifetime before the user receives them. There is no universal safe offset to assume across compatible object-storage implementations, so alert thresholds and TTL margins should be chosen from measured host offset and the service's documented signing rules. Your mileage may vary.
For a platform team, the SLO should describe the thing a user does: “an authorized recipient can retrieve a completed export during its stated validity period.” Issuance latency belongs below it as a contributing service metric. A green signer endpoint says little about a request evaluated later at the storage edge.
That distinction changes instrumentation. Count exports written, verified, links issued, and client-visible retrieval outcomes where privacy and product design permit. Sample failed delivery reports by reason category, but avoid placing temporary URLs in logs, traces, support tickets, or analytics events. A leaked log line has the same access semantics as the link itself until it expires.
Choosing the handoff mechanism
Temporary direct links are attractive because application servers do not carry every export byte, yet they move observability and revocation concerns to the edge. A proxy download endpoint carries more operational responsibility but gives the application a decision point on every request. Neither model wins by default.
| Handoff design | What it improves | What it costs |
|---|---|---|
| Temporary direct object link | Keeps bulk bytes off the application path | Limited per-download application visibility and no per-link revocation after issuance |
| Application download proxy | Allows authorization and auditing at download time | Streaming capacity, connection management, and an additional data path |
| Asynchronous export with a retrieval page | Separates job completion from recipient delivery | More state to operate and a user-facing workflow |
The catch is that a direct link is not suitable when a policy requires immediate, individual revocation, when each download must produce an application audit event, or when the recipient's authorization needs to be re-evaluated at click time. Use a mediated download path in those cases. Conversely, a proxy can be needless capacity work for large, low-sensitivity artifacts with a narrow expiry window and a clear edge-log strategy.
Test the selected design as a path, not as an SDK call. Put fixtures through keys containing spaces, percent signs, plus signs, reserved characters, and non-ASCII characters. Exercise expiry from hosts with intentionally offset clocks in an isolated test environment. Confirm that a client following the production hostname does not alter signed components. Verify browser behavior separately from command-line retrieval, because a CORS failure is a browser policy result rather than proof of a bad signature.
Small tests catch expensive assumptions.
A practical failure drill
Run this drill before changing a signer implementation:
- Locate the export record and copy its opaque stored key, bucket, completion time, issuance time, and absolute expiry.
- Use a diagnostic principal to perform a metadata lookup for that exact key, keeping its broader permissions out of the customer-facing link flow.
- Compare the request method, authority, path, query parameters, and signed headers at issuance and at receipt.
- Check signing-host clock offset and whether the recipient clicked inside the recorded validity interval.
- Reproduce with a controlled client, then separately inspect browser developer tools for CORS enforcement.
This gives the incident commander a decision tree with owners: export pipeline, signer, DNS or proxy layer, access policy, time synchronization, or browser integration. It also avoids a common capacity-planning error. Scaling the API that creates links does nothing for a delivery failure caused by object naming or a clock budget; scaling a proxy does nothing for a browser blocked before it can read a response.
References
- MDN, Cross-Origin Resource Sharing (CORS): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- AWS, Amazon S3 pricing: https://aws.amazon.com/s3/pricing/
- RFC 3986, Uniform Resource Identifier (URI): Generic Syntax: https://www.rfc-editor.org/rfc/rfc3986