Short answer: Azure Blob Storage, Google Cloud Storage, and Amazon S3 all provide temporary download authorization; none makes retention proof automatic, so private user-upload expiry must be enforced in the application rather than inferred from a cloud storage URL.
Design the upload system around a deletion deadline, then add expiring download authorization on top. For a game that accepts private replays, screenshots, and crash bundles, the durable question is not which object store has the nicest upload SDK. It is whether the system can prove that an object stops being downloadable and is eventually removed when its retention period ends.
My decision rule is to keep the application as the source of retention intent, keep object storage as the byte store, and make every download pass through an authorization check that knows the object\'s tenant, state, and expiry. A signed URL is a short-lived capability, not a deletion policy. Treating it as both creates a quiet failure: an object can be deleted from the database while an already-issued URL remains usable until its signature expires.
The invariants I would put in the decision record
The gaming scenario is concrete. A player uploads a private replay for moderation. The service stores the bytes, records an owner and an absolute delete_at timestamp, and returns a download link to an authorized moderator. A normal retention window might be 30 days; the number is a product policy, not a storage-provider default.
The invariants are stricter than the happy path:
- An upload is never publicly readable.
- The database record is the authority for owner, object key, lifecycle state, and
delete_at. - A download authorization cannot outlive the record\'s retention deadline.
- Deletion is idempotent, observable, and retried after a transient failure.
- A client retry cannot create a second logical upload for the same idempotency key.
That last point matters because mobile game clients disappear between an upload and the confirmation request. I use a generated object key rather than a user filename, and I do not mark the record available until the server has evidence that the upload completed. The byte store can contain an orphan; the application must not quietly treat an unconfirmed orphan as user content.
Here is the comparison I would record before choosing an implementation. The names describe different APIs and control planes, not interchangeable feature labels.
| Service | Temporary download mechanism | Retention and deletion concern | Useful boundary |
|---|---|---|---|
| Amazon S3 | Presigned URLs | Lifecycle configuration and application deletion are separate controls | A presigned URL grants time-limited access to a specific operation |
| Azure Blob Storage | Shared access signatures | SAS expiry and blob lifecycle management are separate policies | A SAS should be scoped by resource, permissions, and time |
| Google Cloud Storage | Signed URLs | Object lifecycle rules and application deletion still need explicit coordination | A signed URL is authorization for a request, not proof of retention completion |
I would not rank these rows. The relevant choice is the surrounding control plane: identity, audit logs, lifecycle configuration, region constraints, and the team\'s ability to test deletion. A signed request from any of the three can be technically correct while the retention workflow remains wrong.
The limitation is operational: a provider-neutral adapter cannot erase differences in signing inputs, permission vocabulary, lifecycle configuration, or audit behavior. The trade-off is deliberate. A small interface keeps retention policy in application code, while adapter tests and runbooks carry the provider-specific details.
What must expire, and what must actually disappear?
These are two different clocks. The first is access expiry: how long a capability may be used. The second is retention expiry: when the object should no longer exist. For private uploads, I normally make the download capability much shorter than the retention window, perhaps 10 minutes versus 30 days. That limits accidental sharing without pretending that a 10-minute URL deleted anything.
There is a third clock in real systems: deletion completion. A worker may receive a due record, issue a delete request, lose its network connection, and retry. The application should represent that uncertainty. available, deleting, deleted, and delete_failed are more useful states than a boolean because an operator needs to distinguish a missing object from a worker that never reached the storage API.
A deletion deadline also needs a defined meaning. Does the team promise that downloads stop at delete_at, or only that deletion starts then? Those are different service guarantees. If downloads must stop at the deadline, the authorization path must reject a record whose deadline has passed even if the physical object still exists for a short time. If physical erasure has a separate target, record that target and measure it.
This distinction is where provider lifecycle rules fit. They are good backstops for abandoned data, but they do not replace application state. A lifecycle rule can remove an object that the application still believes is available, and an application delete can race a lifecycle transition. The reconciliation job should compare expected records with observed storage state and produce a reviewable result, not silently repair every discrepancy.
How should Azure, Google, and Amazon cloud storage handle private uploads?
The service should issue upload and download capabilities only after checking authorization. The following Python example is deliberately provider-neutral; the sign function is an adapter around the selected storage API, while the policy stays in application code.
from dataclasses import dataclass from datetime import datetime, timedelta, timezone @dataclass(frozen=True) class UploadRecord: owner_id: str object_key: str state: str delete_at: datetime def issue_download_url(record: UploadRecord, requester_id: str, now: datetime) -> str: if record.owner_id != requester_id: raise PermissionError("upload owner mismatch") if record.state != "available": raise PermissionError("upload is not available") if now >= record.delete_at: raise PermissionError("retention deadline has passed") remaining = record.delete_at - now lifetime = min(remaining, timedelta(minutes=10)) return sign( method="GET", object_key=record.object_key, expires_at=now + lifetime, ) For upload completion, I would use a server-side confirmation path with an idempotency key. The client can retry confirmation; the transition from pending to available must be conditional. A background job then claims due records in small batches, attempts deletion, and records the provider response category. Do not put provider request bodies or signed URLs in ordinary application logs.
For upload completion, I would use a server-side confirmation path with an idempotency key. The client can retry confirmation; the transition from pending to available must be conditional. A background job then claims due records in small batches, attempts deletion, and records the provider response category. Do not put provider request bodies or signed URLs in ordinary application logs.\n+
A useful test set includes an expired record, a record with five minutes left, a retry after a successful delete, a missing object, an authorization attempt by another player, and a worker crash between the storage delete and the database update. Those tests expose the boundary better than a screenshot of a successful upload.\n+
Which failure boundary deserves the most attention?\n+
The dangerous option is to let the client hold a long-lived URL and rely on the object store\'s lifecycle policy for cleanup. It is attractive because the implementation is small. It is also the wrong contract for private moderation data: a copied URL can remain usable after an account action, and the application cannot enforce per-record authorization once the capability has escaped.\n+
That option still has a valid use case. For public, immutable game patches or assets whose access policy is intentionally broad, a cacheable URL and provider-managed lifecycle may be sufficient. Private user uploads have a different threat model.\n+
The other common failure is deleting the database row first and treating a later storage error as an edge case. The row disappears from normal queries, the object remains billable and potentially reachable through an old capability, and nobody has a durable queue item to retry. A tombstone or deletion record should survive the logical deletion.\n+
Cross-provider portability has a cost too. Amazon S3 presigned URLs, Azure Blob shared access signatures, and Google Cloud Storage signed URLs do not share identical signing inputs, permission vocabulary, or operational configuration. A generic interface can hide those differences in business code, but it cannot eliminate them from adapter tests, deployment configuration, or incident runbooks. Abstraction is useful here only if the contract names the differences instead of flattening them into a misleading get_url() helper.\n+
The operational proof is part of the architecture\n+
I would alert on records past delete_at that remain in an accessible state, failed deletion attempts by age, orphaned objects without application records, and signed-download authorization denials near the deadline. Metrics should separate authorization failures, missing objects, provider throttling, and internal queue delay. One total called storage_errors will conceal the failure that matters.\n+
Run a small deletion drill before launch and repeat it after changing lifecycle configuration. Seed an upload, confirm that a valid moderator can retrieve it, advance the test clock, verify that new authorization is rejected, process deletion twice, and inspect both the application record and the storage inventory. The expected result is boring: the second delete is harmless, the audit trail is present, and no new link is issued after the deadline.\n+
The final choice among storage services should follow the organization\'s existing identity, logging, data-location, and operational constraints. For this scenario, the winning architecture is the one that makes retention a state transition the team can inspect and test. Storage APIs hold bytes. The application still owns the promise about who may retrieve them and when that promise ends.\n+
References\n+
- AWS, “Presigned URLs”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- AWS, “Object lifecycle management”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- Microsoft Learn, “Use shared access signatures (SAS)”: https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview
- Microsoft Learn, “Blob Storage lifecycle management policies”: https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview
- Google Cloud, “Signed URLs”: https://cloud.google.com/storage/docs/access-control/signed-urls
- Google Cloud, “Object Lifecycle Management”: https://cloud.google.com/storage/docs/lifecycle
- RFC Editor, “RFC 7234: HTTP Caching”: https://www.rfc-editor.org/rfc/rfc7234
![AMR Mechanical and Electrical Engineering — Ch.12: Contents {#contents .TOC-Heading} [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzu7uvk9b9zcljsl3gz5.png)
![AMR Mechanical and Electrical Engineering — Ch.14: Contents {#contents .TOC-Heading} [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nel7ut2runbhcvsezvf.png)