A support agent needs a download link for a batch of product images. The storage system needs a different answer: a link that expires, names one exact object, and can only be created after the requesting user has passed the tenant check. Those requirements make a signed URL an authorization handoff, not a harmless convenience endpoint.

Short answer: keep tenant authorization in your application, keep the signing key there too, and sign a narrowly scoped object request only after both checks succeed. Return the URL with a short lifetime, record the decision, and treat the URL as a bearer credential until it expires. A browser can then download directly from object storage without receiving storage credentials or forcing the application server to stream every image.

The important boundary is delivery simplicity versus access control. For a customer-support export, I would choose direct delivery with a server-issued, tenant-bound URL, then add a manifest and an audit record. I would not let a client construct a storage key from a ticket number and ask the bucket to sort out authorization later.

What must the link prove

A URL alone does not prove that its holder belongs to the right customer. It proves only that the holder possesses a signed request. That distinction is easy to miss when the first test is “the image downloaded.”

The application should resolve a stable internal export identifier to a server-owned record. That record contains the tenant identifier, object key, content type, and export status. The requester's session or token supplies identity; the database lookup supplies ownership. Only then should the signer create a URL for the exact object.

For example, a support export might contain tenant-4821/exports/exp_7f3/images/img_009.webp. The object key is an implementation detail, not an authorization input. The client can ask for exp_7f3; it should not be able to replace tenant-4821 with another tenant's prefix, even if it guesses a valid-looking key.

The signed request should constrain more than the path. Where the storage service supports it, bind the method, expiry, and response headers that matter to the workflow. A download link should not quietly become a long-lived upload capability. Keep the lifetime short enough that a copied link has limited value, while leaving enough time for a slow support agent on a corporate network to use it.

That is a policy decision, not a magic number. I start with 300 seconds for an individual image link and verify the choice against the export's expected size, proxy behavior, and support workflow. A ten-minute URL is still a bearer token.

The link is disposable.

Treat the export record as the source of truth.

How do I generate pre-signed download URLs for object files?

Because signing the wrong object makes the wrong decision durable for the lifetime of the URL.

The safe sequence is deliberately boring:

  1. Authenticate the caller and load the export by an opaque identifier.
  2. Confirm that the caller's tenant matches the export's tenant and that the export is ready.
  3. Load the object key from the trusted export record, never from a free-form path parameter.
  4. Ask the storage adapter to sign one GET request with an expiry and download disposition.
  5. Record who requested the link, which export was named, and when the link expires.

The adapter should have no concept of support-agent roles or customer accounts. That belongs to the application. The adapter's job is narrower: accept a previously authorized object reference and produce a provider-specific signed request. This separation keeps the access decision testable and prevents a storage SDK detail from becoming an accidental policy engine.

The same boundary applies when the caller is a Node.js service: let its route handler authenticate and resolve the export, then pass a trusted object reference to a storage adapter. Node.js is an implementation choice; it does not change the tenant check or the fact that a pre-signed URL is a bearer credential.

Here is the shape I use for the application boundary. The signing call is intentionally an interface, because the exact SDK differs between object-storage services and the authorization logic should not.

from datetime import datetime, timedelta, timezone def issue_download_link(caller, export_id, repository, signer, audit_log): export = repository.get_export(export_id) if export is None or export.status != "ready": raise PermissionError("export is unavailable") if export.tenant_id != caller.tenant_id: raise PermissionError("export is outside caller tenant") expires_at = datetime.now(timezone.utc) + timedelta(seconds=300) link = signer.sign_get( object_key=export.object_key, expires_at=expires_at, response_content_disposition= f'attachment; filename="{export.download_name}"', ) audit_log.record( actor_id=caller.user_id, tenant_id=caller.tenant_id, export_id=export.id, expires_at=expires_at, ) return {"url": link, "expires_at": expires_at.isoformat()} 

The filename deserves attention. Treat it as a response-header value, normalize it when the export record is created, and test quotes, control characters, and non-ASCII names. The URL should not be assembled by string concatenation. Signature formats are sensitive to canonical paths, query ordering, headers, and clock skew; use the storage service's maintained signing implementation behind the adapter.

What changes across storage services?

The design is portable, but the edges are not identical. Amazon S3 uses query-based presigned requests and supports response-header overrides in its request model. Google Cloud Storage offers signed URLs with its own signing schemes and expiration rules. Azure Blob Storage uses a shared access signature with permissions and resource scope expressed in its token. These are different credential formats, so a verifier or hand-rolled signer should not be shared across them.

The useful comparison is operational. Check which identity is allowed to sign, how the service represents expiry and permissions, whether the object endpoint is reachable from the browser, and which response headers can be constrained. Also check revocation behavior: an issued bearer URL generally remains usable until its expiry unless the underlying object, signing key, or access conditions change.

This is where delivery simplicity can mislead a team. Direct browser delivery reduces application bandwidth and usually makes a large export easier to serve. It also moves the final request across a second origin, so browser behavior becomes part of the contract. CORS is a browser policy that controls whether frontend JavaScript can read a cross-origin response; it does not replace authorization on the storage service. If the browser only navigates to an attachment, the requirements differ from a JavaScript client that needs to inspect headers or read the response body. Test the actual interaction.

A server-proxied download gives tighter control over revocation and response handling, but the application now carries the bytes, connection limits, retry behavior, and observability burden. I reserve it for cases where immediate revocation, content transformation, or a private network boundary matters more than direct delivery.

Approach Integration shape Best fit Main limitation
Direct pre-signed URL Storage SDK or signing adapter Large files and simple browser downloads Revocation is limited after issuance
Application proxy Application endpoint streams bytes Immediate policy checks or transformation Application bandwidth and retry work increase
Provider-specific token Service API and native token format Teams already operating that storage service Token semantics differ between services

This pattern has limits. It is a poor fit for a workflow that requires every byte to be re-authorized during a long download, because expiry does not interrupt an already accepted request. Use a proxy or a private transfer service when that boundary is mandatory.

How should the export roll out safely?

Start with one export type and a small fixture set: an image owned by tenant A, an image owned by tenant B, a missing object, an expired link, a filename containing quotes, and a slow response. The cross-tenant case must fail before the signer is called. That assertion catches a more important bug than a successful happy-path download.

At the API boundary, return the expiry timestamp alongside the URL so clients can avoid treating the link as permanent. Do not log the full URL; its query parameters may be sufficient to download the object. Log the export identifier, tenant, actor, decision, and expiry instead. Metrics should separate authorization denials, signing failures, storage 404s, expired requests, and browser CORS failures.

For a migration, keep the existing download path available while the new flow writes audit events and compares outcomes. Then switch one tenant cohort, watch denial and expiry rates, and remove the old path only after links from the old route can no longer be issued. Key rotation and object deletion are part of the incident plan, because expiry is not a revocation system.

The decision rule is compact: authorize with tenant data in the application, sign only a server-resolved object reference, expose the smallest useful download capability, and measure the handoff. The storage provider can deliver the bytes. It cannot infer why this support agent should see this export.

Sources and References