When you add file storage to an API, the first decision is: how do you get those files to the client?
There are three fundamentally different strategies, each with different security properties, infrastructure requirements, and tradeoffs. Most tutorials pick one without explaining why. This article covers all three so you can choose the right one per use case — because in most real applications you'll need more than one.
The three strategies
1. Public URL
The file is stored in a public bucket. Anyone with the URL can access it — no authentication, no expiry, no server involvement after upload.
Client → CDN/S3 URL → File
When it makes sense:
- Static assets that are genuinely public: logos, marketing images, open documentation
- Files you'd serve from a CDN anyway
- High-traffic read scenarios where you don't want your API in the critical path
Limitations:
Once the URL is out, it's out. You cannot revoke access to a specific file without deleting or moving it. If a user shares the URL, anyone can use it indefinitely. This rules it out for anything tied to permissions or subscriptions.
Also worth noting: if you later need to privatise a file, you have to migrate it to a different bucket with a different access policy. Plan your bucket layout with this in mind.
2. Presigned URL
The file lives in a private bucket. Your API generates a time-limited signed URL and returns it to the client. The client uses that URL directly to fetch the file — your server is not in the data path.
Client → API (auth check) → generates signed URL → Client → S3 URL (expires in N seconds)
When it makes sense:
- Files that belong to a specific user or entity
- Content that should expire (paid downloads, temporary access, trial periods)
- Large files where you don't want to stream through your server
The key parameter: expiry. Too short and clients get errors mid-session. Too long and you've effectively made it public.
The cache problem — easy to miss:
If you cache the API response that contains the presigned URL, and that cache entry outlives the URL's expiry, clients will receive a cached response with a URL that no longer works. The fix is to align your cache TTL with the presigned URL expiry. If the URL expires in 1 hour, the cache entry must expire in at most 1 hour.
One infrastructure note: if your API runs in Docker and connects to a self-hosted S3 backend via an internal network address, presigned URLs need special handling. The signature is computed against the URL host, so if you sign with an internal Docker hostname, the resulting URL will contain that internal hostname — which the client cannot reach. You need to sign with the public-facing hostname.
3. Proxy
The file stays in a private bucket. The client never gets a storage URL at all — it requests the file through your API, your server fetches it from S3, and streams it back to the client.
Client → API (auth check) → S3 (internal) → streams back to Client
When it makes sense:
- Files that require strict access control checked on every request
- Scenarios where you need to log every access
- Files where the storage URL must never leak (contracts, medical records, invoices)
- Cases where you need to transform the file on the way out (watermarks, transcoding)
The cost:
Every download passes through your server. For large files or high traffic this adds bandwidth and compute costs, and your server becomes the bottleneck.
Use this mode when security requirements genuinely demand it, not as a default.
Comparison at a glance
| Public URL | Presigned URL | Proxy | |
|---|---|---|---|
| Storage bucket | Public | Private | Private |
| Auth check on download | No | At generation time | On every request |
| URL expires | No | Yes (configurable) | N/A |
| Server in download path | No | No | Yes |
| Can revoke access | Only by deleting | Naturally (expiry) | Yes |
| CDN-friendly | Yes | Limited | No |
| Best for | Static assets | User content | Sensitive files |
Per-bucket configuration
In most real applications you'll have multiple buckets, each warranting a different strategy. A typical setup might look like this:
-
public-assets— logos, marketing images → Public -
user-uploads— profile pictures, product photos → PresignedUrl (7-day expiry) -
private-documents— contracts, invoices → Proxy
The cleanest approach is to make the access mode a property of the bucket configuration rather than hardcoding it in business logic. Your file-handling code then reads the bucket config to decide how to resolve a download URL — no conditionals scattered through your services.
This also means changing a bucket's access mode is a config change, not a code change.
Choosing
A rough decision tree:
- Is the file genuinely public and static? → Public URL
- Does the file belong to a user but doesn't need per-request access checks? → Presigned URL
- Does the file require access checked on every request, or must the URL never leak? → Proxy
When in doubt, start with Presigned URL — it gives you access control at generation time, natural expiry, and keeps your server out of the download path. Upgrade to Proxy only when you genuinely need per-request enforcement.
A note on provider choice
Everything above applies equally to AWS S3, Garage or any S3-compatible backend. The access mode strategy is independent of the provider — only the endpoint and credentials change. If you're self-hosting, Garage is worth a look: it's lightweight, runs in Docker, and is S3-compatible.
I implemented all three modes in FenixKit — a .NET Minimal API kit that comes with MongoDB, Keycloak, Redis, and S3 file storage pre-wired. Each bucket is configured independently with its own access mode. If you're building a .NET API and don't want to wire all this up from scratch, it might save you a few days. Public docs on GitHub













