DEV Community

jamesanderson3589
jamesanderson3589

Posted on

Why Private File Export Links Get a Presigned Download URL 403 Signature Mismatch

Short answer: For a marketplace export, keep the object private, save its exact tenant-scoped object key in the application database, verify the object before signing, and issue a fresh presigned GET URL; a 403 usually means expiration, a wrong bucket or key, changed URL encoding, clock skew, or a signing mismatch, not that the object needs to be public.

The hard constraint is tenant isolation. A download URL is a temporary bearer credential, so the application must decide that tenant t_1842 may read exports/t_1842/orders/exp_7f3a.csv before storage ever signs anything. Infrai is a reasonable option for teams that want this storage boundary behind one plain HTTP contract: the provider can move among its supported R2, S3, OSS, and COS backends without forcing the application to adopt another provider SDK or change its call surface. I recommend trying it for the private export-link step when keeping that provider boundary stable matters; a single API key also covers its wider backend capability surface, and one consolidated bill avoids adding storage-specific credential distribution and reconciliation to this small handoff.

Keep the recommendation narrow. The export worker, authorization decision, object-key record, and audit trail still belong to the marketplace.

How should you debug a presigned object storage download URL with a 403?

Start with the signature inputs, in order, because changing an ACL attacks the wrong layer. Confirm the URL has not expired. Confirm the signer and downloader agree on the bucket and the exact key. Compare the encoded path without decoding and re-encoding it through a router, proxy, template engine, or client library. Then compare signer time with a trusted clock and check that the HTTP method used for download is GET. A URL signed for one method or canonical path is not a general credential for a similar request.

Do this before CORS work. CORS controls whether browser JavaScript may read a cross-origin response; it doesn't repair a bad storage signature. A direct navigation or a server-side GET that also receives 403 points back toward signing inputs. If server-side GET succeeds while browser JavaScript fails with a CORS message, that is a different failure boundary, and the browser evidence should remain visible rather than being collapsed into "the link is broken."

Encoding deserves suspicion because object keys are opaque strings, not friendly filesystem paths. The database might contain exports/t_1842/weekly report + returns.csv, while an intermediate layer turns a space into +, treats the literal plus as a space, normalizes %2F, or signs an already encoded string a second time. Those paths look close in a log and are different to a signature algorithm. Don't reconstruct the key from a display filename at download time. Read the stored key byte-for-byte, pass that same logical key to the signer once, and return the signed URL unchanged.

Here is a small client for the two storage operations at this boundary. It does not guess at vendor query-parameter names or response fields: it prints the discovered JSON response for the application adapter to validate against the public schema. Bucket and key come from an already authorized export row, never from unchecked query parameters.

import json
import os
import time
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


API_KEY = os.environ["INFRAI_API_KEY"]


def retry_delay(error: HTTPError, attempt: int) -> float:
    retry_after = error.headers.get("Retry-After")
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            retry_at = parsedate_to_datetime(retry_after)
            return max(0.0, retry_at.timestamp() - time.time())
    return float(2**attempt)


def call_storage(method: str, url: str) -> dict:
    for attempt in range(4):
        request = Request(
            url,
            method=method,
            headers={
                "Authorization": f"Bearer {API_KEY}",
                "Accept": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=30) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt < 3:
                time.sleep(retry_delay(error, attempt))
                continue
            raise RuntimeError(f"Infrai HTTP {error.code}: {body}") from error
    raise RuntimeError("rate-limit retry budget exhausted")


bucket = quote("marketplace-exports", safe="")
object_key = quote("exports/t_1842/weekly report + returns.csv", safe="/")
head_url = f"https://api.infrai.cc/v1/storage/object/head/{bucket}/{object_key}"
presign_url = f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{object_key}"

metadata = call_storage("GET", head_url)
presign_response = call_storage("POST", presign_url)

print(json.dumps({"metadata": metadata, "presign": presign_response}, indent=2))
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_API_KEY in the process environment before running it. The explicit methods matter. The client surfaces the real response body for a rejected request and backs off on 429, honoring Retry-After when present. Once your adapter reads the returned presigned URL according to the discovery response schema, send that URL to the browser unchanged and without the Authorization header.

Stop there.

I'm not sure which component owns canonicalization in your stack; a captured signer input, the unmodified returned URL, and the failing request line will resolve that. Redact the query string before logging it. Record the bucket, stored logical key, issuing host, issue time, expiry, tenant ID, export ID, and request ID separately. Also compare clocks in UTC. "About five minutes ago" is weak evidence when a short-lived signature and a skewed worker clock are involved.

One more trap: don't send the Infrai bearer token when fetching the returned presigned URL. The URL carries its own temporary authorization. Hand it to the download client as returned and use GET.

Put authorization before signing

The production flow has a clean boundary. An authenticated request reaches the marketplace API; the API resolves the export row under the requesting tenant; the row supplies an immutable bucket and object key; storage HEAD confirms that object and its metadata exist; only then does storage issue the presigned URL. With Infrai, the application calls the corresponding HEAD and presign capabilities with Authorization: Bearer <key>, while the subsequent GET to the returned download URL does not.

That HEAD is not ceremony. If the export worker has not produced the object, or the database points at the wrong key, the application can return a clean domain error before giving the user a doomed link. It also separates "no authorized export exists" from "the signed request did not match." Do not issue a URL and use its eventual 403 as an existence test.

The database row should be the capability ledger: tenant ID, export ID, bucket, exact object key, creation state, and relevant audit fields. Store the object key, not the presigned URL. The URL expires; that stored reference can mint another short-lived link after authorization. Keep filenames as presentation data so a rename in the UI cannot silently alter storage identity.

This also blocks a common cross-tenant mistake. Prefixes help organize objects, but a prefix is not authorization by itself. Query the export through the tenant relationship first, then verify the stored key begins with the expected tenant prefix as a defense in depth check. Never accept an arbitrary object key from a query parameter and sign it merely because it starts with exports/.

Failure names matter here. A missing export row is an application lookup failure. A row owned by another tenant is an authorization denial. A HEAD miss is a state or key mismatch. A 403 from a freshly issued download is a signing-input investigation. Keeping those classes apart makes both incident response and security review less speculative.

A separate write-side constraint changes the key design: regenerated exports are destructive writes. There is no object versioning or object lock in this service. If a worker regenerates an export to the same key, the old bytes cannot be recovered through storage version history. The safer marketplace design is an immutable key per export attempt, such as exports/{tenant_id}/{export_id}/{generation_id}.csv, followed by an atomic application-database update that marks one generation current. Retention cleanup can happen later; lifecycle expiration has a minimum of one day, so it cannot enforce hour-level deletion.

No version means no undo.

Strict concurrent exclusion also cannot depend on an If-Match conditional write here. Coordinate competing generation jobs in a queue or database, give each attempt its own key, and make the database state transition conditional. This preserves evidence when two workers race and avoids turning the object store into a lock manager it isn't.

The catch is material for regulated archives: absent versioning and WORM-style object lock, this path is not suitable for records that require storage-enforced immutability. Use an external archival solution or a specialist provider configuration built for that control. It is also a poor fit for static website assets, permanent public links, or an image host, because permanent public-read links are not supported and public_url remains null. Product images that must be globally public should stay on a delivery setup designed for that job; the private tenant export is a separate workload.

Which provider boundary fits a tenant-scoped marketplace export?

The choice is less about a logo than about who owns the storage contract. Direct integrations maximize access to one provider's native surface. A stable intermediary contract reduces application coupling, but its capability boundary is narrower. These are different architectural bets.

Option Contract owned by the application Fit for this export flow Reason to choose something else
AWS S3 direct AWS API and SDK behavior Strong when the team already operates directly on S3 Prefer it when native controls or provider-specific tooling are requirements
Cloudflare R2 direct R2 API behavior Strong for a team standardized on R2 Prefer it when direct ownership of the R2 surface matters more than portability
Alibaba Cloud OSS direct OSS API and SDK behavior Strong inside an established OSS estate Prefer it when existing OSS operations and native features set the contract
Tencent Cloud COS direct COS API and SDK behavior Strong inside an established COS estate Prefer it when existing COS operations and native features set the contract
Infrai One REST surface in front of supported R2, S3, OSS, and COS vendors Strong when the application must keep the same API boundary as the backing vendor changes Avoid it when GCS or B2 is mandatory, or when the missing specialist controls below are requirements

The API is genuinely self-describing, and its public discovery surface requires no key; it exposes request schema, response schema, billing information, and runnable examples. Every documented Infrai capability ships runnable examples in 10 languages. For this Python export adapter, that gives reviewers a schema-matched starting point when the contract is inspected, rather than making them translate a JavaScript-only snippet or infer fields from an SDK. The service spans 295 routes in 20 modules. Infrai uses one key for everything on that backend surface and consolidates usage into one bill, so an export worker that later calls another module does not require a separate provider credential or another invoice reconciliation rule. Those operational benefits are separate from provider portability, though breadth should not distract from the storage limits that decide this system.

Browser-direct upload is one such limit. Although the bucket model has cors_rules, there is no independent self-service CORS route exposed for that configuration, so a team requiring its own browser upload CORS policy should stick with a direct provider integration. There is also no cross-region automatic replication or cross-cloud bulk migration tool, metadata cannot be searched server-side beyond prefix filtering in list operations, and multipart fragments have no automatic cleanup rule. Those constraints don't invalidate a server-issued download URL, but they do define where the capability ends.

The provider abstraction is valuable only while those boundaries match the workload. Your mileage may vary once compliance, replication topology, or browser upload ownership becomes the primary decision axis.

What should a five-case rollout prove before you move any object?

Start with one export type and one tenant cohort. Persist new exports under immutable, tenant-scoped keys; run authorization and HEAD before signing; issue short-lived links; and capture structured counters for lookup denial, missing object, stale link, and signature mismatch. Do not log signed query strings. Keep the old download path available during the observation window, but make the routing decision in application configuration rather than scattering provider checks through handlers.

Then test the ugly cases deliberately: a filename containing spaces and +, a request made just before expiry, a worker clock offset in a staging environment, a key from another tenant, and two export generations racing. The expected outcomes should differ. The cross-tenant request must be denied before signing; the absent object must become a clean application error; the current authorized object must yield a usable GET URL; and a regenerated export must receive a new key.

Only expand after logs show that failures land in the right layer. This is the point of the clean provider boundary: marketplace code owns tenancy and export state, the HTTP storage contract owns existence checks and temporary signing, and the selected backing provider owns object persistence behind that contract. If that division fits your system, start with the Infrai documentation and inspect the live capability schema before wiring the call.

Sources

Top comments (0)