Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

5 Ways to Generate Presigned File Download URLs: Tenant Retention Rules

5 Ways to Generate Presigned File Download URLs: Tenant Retention Rules Short answer: Generate a short-lived presigned file download URL only after checking the tenant-scoped export record, and pair it with an explicit

5 Ways to Generate Presigned File Download URLs: Tenant Retention Rules

Short answer: Generate a short-lived presigned file download URL only after checking the tenant-scoped export record, and pair it with an explicit deletion schedule.

The least complicated design is a short-lived presigned download URL backed by an explicit retention record. The export service decides who may request a link, the object store decides whether the signature is valid, and a deletion worker removes the image and its export metadata on a known schedule. That division matters more than the particular S3-compatible implementation.

A logistics system usually has two different things to retain: the product image and the generated export. They should not inherit the same lifetime by accident. A product photo might support a dispute for 30 days; a CSV or ZIP assembled for a tenant might be useful for 24 hours. The download URL should live for minutes, not become a second retention policy.

What is the bill actually made of?

For this workflow, storage cost is dominated by retained bytes and request activity, not by the few lines that sign a URL. A useful first estimate is:

retained gigabytes = daily uploaded image gigabytes x image retention days + daily export gigabytes x export retention days

Suppose a carrier portal receives 18 GB of product images each day, keeps source images for 30 days, and produces 6 GB of tenant exports each day. If exports stay for 24 hours, the rough retained set is 540 GB of images plus 6 GB of exports, before replicas, metadata, and incomplete multipart uploads. Letting exports sit for 30 days changes the second term to 180 GB.

That is the change I would make first: shorten export retention and record the decision, rather than weakening image quality or hiding storage behind a larger quota. The trade-off is concrete. A dispatcher who requests an old export may need to generate it again, and an incident investigator may have to rely on audit metadata instead of the original archive.

Presigned URLs do not delete objects. They grant temporary access to an object key under the permissions and signing rules of the issuer. Deletion needs its own lifecycle: an expiration timestamp, a worker or lifecycle rule, and a reconciliation path for work that stopped halfway through.

How do I generate a presigned URL for file download?

A link lifetime should be derived from the user action, not copied from object retention. For a browser download, 10 minutes is often enough to request the file, follow a redirect if the client uses one, and retry a transient network failure. A large export on a slow warehouse connection may need longer. The value belongs in configuration with an upper bound.

The request path should look roughly like this:

  1. Authenticate the operator and resolve the tenant from server-side authorization data.
  2. Load an export record whose tenant, object key, status, and expiration are all checked together.
  3. Sign a GET request for that exact key with a short lifetime.
  4. Return the URL without exposing storage credentials or allowing a caller to choose an arbitrary key.
  5. Write an audit event for the request, not the secret URL itself.

The tenant check is the important part. A URL can be cryptographically valid and still be the wrong URL for the caller if the application selected an object by an untrusted filename or guessed key. Object names are identifiers, not authorization.

Here is deliberately small Python-shaped pseudocode for the decision boundary. It shows the checks that belong before a generic S3-compatible signing call; the storage SDK call is intentionally represented by an interface so the policy does not become coupled to one library.

from datetime import datetime, timedelta, timezone

MAX_LINK_AGE = timedelta(minutes=15)

def make_download_link(actor, export_id, repository, signer, now=None):
    now = now or datetime.now(timezone.utc)
    export = repository.find_export(export_id)

    if export is None or export.tenant_id != actor.tenant_id:
        raise PermissionError("export is not available")
    if export.status != "ready" or export.expires_at <= now:
        raise FileNotFoundError("export is not downloadable")

    lifetime = min(export.requested_link_age, MAX_LINK_AGE)
    return signer.presign_get(
        bucket=export.bucket,
        key=export.object_key,
        expires_in=int(lifetime.total_seconds()),
    )

The same boundary applies in a Node.js service: resolve the record, authorize the tenant, cap the lifetime, then call the SDK's presigning helper. The syntax changes; the security decision does not.

What can go wrong after the URL is issued?

The common failure is treating a successful signing call as a completed download. It is only a handoff. The browser may have no CORS permission to read the response, a proxy may reject a large response, or the object may be deleted while the link is still valid. A client that needs to trigger a download without reading response bytes can use a normal browser navigation; a client that reads the response from another origin needs an explicit CORS policy, including the methods and exposed headers it actually uses. The MDN CORS guidance is a good reference for the browser side, but it does not replace authorization in the export service.

A second trap is logging the full URL. Query parameters in a presigned URL are bearer credentials until they expire. Application logs, browser history, support screenshots, analytics referrers, and tracing attributes can all extend the audience beyond the original tenant user. Log the export ID, tenant ID, actor ID, and expiration time. Redact the URL.

A third trap is key reuse. If a new export overwrites an old object key, a link issued for the old export can point at new bytes. Use an immutable export identifier in the object key, or make replacement impossible. It is easier to reason about exports/{tenant_id}/{export_id}.zip than about latest.zip.

How should deletion and recovery work?

Keep three timestamps: created_at, available_until, and deleted_at. available_until is the business retention decision. The deletion process can run after that time, but the record should say when the object was actually removed. This makes a delayed worker visible without pretending that a policy clock and a storage operation are the same event.

I would use an idempotent deletion job with a small state machine: ready, delete_requested, deleted, and delete_failed. A retry must be safe. A missing object should usually converge to deleted, while an authorization or service failure should remain visible for retry and alerting. Keep the audit record after deleting the bytes; it is the evidence that the export existed and who requested it.

The job also needs a reconciliation query. It should find export rows past available_until that are not deleted, plus objects under the export prefix that have no matching row. The first set catches failed work. The second catches abandoned generation paths. Do not make reconciliation depend on a single in-memory queue.

There is a deliberate loss here. After deletion, support cannot download the old archive just because an audit event exists. That is the point of the retention decision, not a defect. If legal hold or claims handling requires preservation, represent that as an explicit hold that changes available_until; do not ask operators to copy files into an undocumented bucket.

What should the test and operations surface prove?

A useful test matrix follows the policy, not just the happy path. Test a valid tenant, a different tenant, a missing export, an expired export, a non-ready export, a link past the maximum lifetime, and a key that has already been deleted. Test the browser path separately from a programmatic cross-origin read. The MDN CORS guidance is a good reference for the browser side, but it does not replace authorization in the export service.

For operations, measure the facts that explain a bad download:

  • link requests allowed and denied, grouped by reason
  • export generation time and byte size
  • download response failures by status and tenant
  • objects past available_until
  • deletion retries and orphaned objects

Do not put signed query strings into metrics labels. High-cardinality secrets are a security problem and a noisy dashboard.
A release check should also verify that the bucket policy, CORS configuration, worker identity, and clock synchronization are consistent with the application assumptions. Signature verification is time-sensitive. A few minutes of clock drift can look like an authorization bug, while an overly broad CORS rule can turn a convenient browser download into an unintended data exposure.

The practical decision rule is compact: keep source images for the business evidence period, keep generated exports only as long as the workflow needs them, issue links for the shortest credible transfer window, and make every deletion observable. That gives a tenant-scoped download flow without allowing a temporary URL to quietly become permanent access.

Further reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.