FilesGENERALCLOUD-SPECIFICPROTOCOL-SPECIFIC

Serving Files

Private files need a check on every read; public files need a CDN. Serving both through your API is the one option that is wrong for both.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

How does a stored object get back to a user, with authorization enforced and without your service moving every byte?

The requirement

Support attachments must be downloadable only by people on the ticket. Product images must load fast for everyone, worldwide. Both currently go through a GET /files/:id endpoint that streams from the bucket.

The obvious build

One endpoint proxies everything: look up the row, check permission, fetch the object from storage, pipe it to the response. It is uniform, the bucket stays private, and authorization is in one place.

Why it breaks

Every byte is paid for twice and passes through your instances. For images loaded on every page view, your API becomes an image server sized by bandwidth rather than by request logic (Egress: Moving Data Costs Money, Not Just Storing It).

How it breaks in production
  • Every byte is paid for twice and passes through your instances. For images loaded on every page view, your API becomes an image server sized by bandwidth rather than by request logic (Egress: Moving Data Costs Money, Not Just Storing It).
  • A long download holds a connection and, depending on the runtime, a worker for its duration — so slow clients on large files consume the concurrency your API needs (Backend Runtime Models).
  • Nothing caches. A CDN cannot cache a response from an authenticated endpoint without careful configuration, so identical images are re-fetched from your origin on every request (CDN as Infrastructure).
  • Range requests, resumable downloads and video seeking all need Range support that a naive pipe does not implement, so video playback breaks in ways that look like a player bug (Request Bodies and Streaming).
  • If the object is served from your primary origin, an uploaded HTML or SVG runs in your origin's security context — the stored-XSS path that upload features are famous for.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • There are three ways bytes reach a user, and they differ in who moves them: proxy (your service reads from storage and writes to the response), redirect to a signed URL (your service authorizes, then answers 302 with a short-lived URL and the client fetches from storage), and CDN (an edge cache serves it, reaching your origin or the bucket only on a miss).
  • Proxying is the only one where authorization is checked on every byte-serving request by your own code. Both other options move the check earlier: you authorize the *issuance* of a URL, and the URL is then a bearer credential until it expires (Presigned URLs).
  • A signed read URL is exactly as private as its handling: it works for anyone who has it, so it appears in browser history, referrer headers, shared links and logs. Short expiry is the control; revocation generally is not available.
  • A CDN caches by URL. That is why caching and per-user authorization pull against each other: a cacheable URL is one many users request identically, and a per-user signed URL is unique per user and therefore uncacheable in the useful sense.
  • Two headers decide how a file behaves in a browser and both are set at storage or response time: Content-Type determines whether it renders or downloads, and Content-Disposition: attachment forces a download regardless. Serving user content from a separate origin is what makes a mistake in either survivable (CORS Without the Myths).
  • Caching headers are the other half. Immutable, content-addressed objects can be cached effectively forever; anything served under a mutable key needs revalidation, which is why versioned or hashed keys are the standard answer (TTL and Expiry).

Three ways bytes get out

The choice is about who moves the bytes and when authorization happens. Proxying checks on every request and moves everything through you. Redirecting checks once and hands out a time-limited credential. A CDN checks nothing per request and is therefore only appropriate for content that is genuinely not per-user.

  • Proxy — full control, per-request checks, Range and caching are yours to implement, and your bandwidth pays.
  • Redirect + signed URL — one check at issuance, no bytes through you, a bearer credential you cannot revoke.
  • CDN — fastest and cheapest, caches by URL, and therefore only for content that is not per-user.
  • Separate origin — orthogonal to all three, and the control that makes a wrong Content-Type survivable.
A. proxy: every byte through youread, then pipe backB. 302 + signed URL (short expiry)client fetches directlyC. public asset, versioned keyorigin fetch on miss onlyClientYour API authorizeCDN edge caches by URLObject storage private bucket
UserLLMAgentToolDataDecisionHumanGuardrail

Choosing per file, not per system

The same product usually needs at least two of these. The deciding questions are whether readers differ in what they may see, how often the object is read, and how large it is.

How should this object be served?

Is access per-user, and how hot is the object?

Proxy through the API

when Small private files, per-request audit requirements, or a check that must happen at read time and cannot be moved earlier.

cost Your bandwidth and connection budget; you must implement Range and caching yourself (Egress: Moving Data Costs Money, Not Just Storing It).

Authorize, then 302 to a signed URL

when Private files of any size where a short-lived credential is acceptable. The default for user documents and attachments.

cost A bearer URL that cannot be revoked before expiry, and clients that do not follow redirects break.

CDN in front of a private bucket

when Public, hot, identical-for-everyone assets: product images, avatars, static media.

cost A cache to reason about; a misconfigured private response cached at the edge is a cross-user leak.

CDN with signed URLs or signed cookies

when Semi-private content that is hot — paid media, per-account video — where you need both edge caching and access control.

cost Provider-specific and fiddly; cache key design decides whether you get any hit ratio at all.

Public bucket, no CDN

when Rarely the right answer. Only for genuinely public, low-traffic assets where cost and latency do not matter.

cost Enumerable, no edge caching, egress charged per request, and hard to make private later (Public Exposure, Read With Context).

The two headers that decide whether an upload is a vulnerability

GENERALContent-Disposition: attachment and a separate origin are both effective on their own and are usually used together; a strict Content-Security-Policy on the user-content origin is a third layer. Note that no-store here is deliberate: a private response that a shared cache may store is the leak described above.

A file feature becomes a cross-site scripting vector through the serving path, not the upload path. What makes it exploitable is the combination of a renderable content type and your own origin; removing either one removes the exploit.

Set these at upload time, on the object, so that every serving path — proxy, signed URL, CDN — inherits them without having to remember.

Serving an uploaded file
App origin, declared type
// GET https://app.example.com/files/9f2c
res.setHeader('Content-Type', attachment.declaredType) // 'text/html'
storage.createReadStream(attachment.key).pipe(res)

// The uploader sends notes.html containing a script.
// It renders on app.example.com, with app.example.com cookies.
Separate origin, stored headers, forced download
// object written at upload time with headers baked in
await storage.put(key, stream, {
  contentType: sniffed,                       // from bytes, allow-listed
  contentDisposition: `attachment; filename="${sanitize(name)}"`,
  cacheControl: 'private, max-age=0, no-store',
})

// GET https://api.example.com/attachments/9f2c/download
const a = await attachments.findForPrincipal(id, ctx)   // authorize
if (!a || a.status !== 'ready') return res.status(404).end()  // and check status
res.redirect(302, await storage.signGet(a.key, { expiresIn: 120 }))
// -> https://files.example-usercontent.com/... different origin entirely

The first serves attacker-chosen markup as attacker-chosen type on the origin that holds your session cookies — stored XSS in two lines. The second decides the type from the bytes, forces a download rather than a render, marks the response uncacheable by shared caches, checks processing status as well as permission, and puts the content on a domain where executing script gains an attacker nothing.

How to build it

Most important first.

  • Split the decision by privacy. Private objects: authorize, then redirect to a short-lived signed URL. Public objects: serve through a CDN from a stable, versioned key and stop thinking about them.
  • Keep buckets private and put the CDN in front with its own credential to the origin, rather than making the bucket public. Public buckets are enumerable and hard to un-publish later (Public Exposure, Read With Context).
  • Serve all user-uploaded content from a separate origin — a dedicated domain, not a path on your app — so a file that renders as HTML has no access to your application's cookies or storage.
  • Set Content-Disposition: attachment and a conservative Content-Type for anything you did not generate yourself. Store both on the object at upload time so the serving path does not have to decide (File Uploads Through the Backend).
  • Use content-hashed or version-suffixed keys for public assets and cache them aggressively; change the key rather than invalidating the cache (Cache Invalidation).
  • Check the file's processing status in the authorization path, not only its permissions — an unscanned file must not be downloadable by anyone but its uploader (What Happens After the Bytes Land).
  • Keep signed read URLs short-lived, and re-issue rather than extending. If a URL must be shareable for days, that is a share link with its own record and revocation, not a longer signature.
  • Support Range for media, or let storage and the CDN do it for you — which is another argument for not proxying video.

What can go wrong

Failure modes
  • Proxying large files on a runtime with bounded workers, so downloads starve the API (Connection Pool Exhaustion).
  • A CDN caching a private response because the origin omitted Cache-Control: private, serving one user's document to the next requester — the highest-severity misconfiguration in this lesson.
  • A signed URL leaked through a referrer header when the file is rendered in a page that links elsewhere.
  • Range requests unsupported, breaking video seeking and resumable downloads with no server-side error.
  • A redirect that the client does not follow — some HTTP clients and SDKs disable redirects by default, and the download silently returns a 302 body.
  • Stale cached objects after a file is replaced under the same key, because the CDN TTL had not expired (TTL and Expiry).
  • An object deleted while a signed URL is still valid, producing a storage 404 the user cannot interpret.
  • Serving a pending file because the download path checked permissions but not status.
What can race
  • An object replaced or deleted while a signed URL for it is still valid — the URL may serve the new content, or a 404, depending on timing.
  • A permission revoked after a signed URL is issued: the URL keeps working until it expires, which is the trade being made when authorization moves to issuance time.
  • A CDN populating its cache from a request made while a file was briefly public or misconfigured, then continuing to serve it after the origin is fixed — the fix is a purge, not a deploy.
Security
  • If a CDN caches an authorized response without Cache-Control: private (or an equivalent), an attacker gets whatever the previous requester was allowed to see, from the edge, with no request to your service at all — a cross-user data leak with no application vulnerability.
  • If user-uploaded files are served from your application's origin, an attacker gets stored cross-site scripting: upload an HTML or SVG file, send the link, and their script runs with your origin's cookies and same-origin privileges (Cross-Site Scripting (XSS)).
  • If the bucket is public, an attacker gets every object in it. Key unguessability slows discovery and is not a permission (Object Storage).
  • If signed read URLs are long-lived, an attacker who obtains one — from a log, a referrer, browser history, a shared screenshot — gets the file for as long as the signature lasts, and typically cannot be cut off (Short-Lived Credentials).
  • If the download path checks permissions but not processing status, an attacker gets to distribute unscanned files through your product to other users during the scanning window (What Happens After the Bytes Land).
  • If Content-Type is taken from what the uploader declared, an attacker chooses how the browser interprets their file — the mechanism behind most upload-to-XSS chains.
  • If a redirect endpoint accepts a client-supplied destination or key rather than deriving it from an authorized row, an attacker gets an open redirect or access to arbitrary objects (Object-Level Authorization).
Misreads
  • "The URL is unguessable, so the file is private." Unguessable is not unauthorized. If the bucket is public, the file is public to anyone who obtains the link, forever (Object-Level Authorization).
  • "We can cache authenticated responses at the CDN." Only with deliberate configuration — a cache key that includes identity, or explicit private directives. The default failure is serving one user's file to another.
  • "Serving through our API is more secure." It is more *checked*, and it also puts attacker-supplied content on your own origin, which is a security regression unless you set the right headers.
  • "A redirect leaks the storage URL." It reveals which provider and bucket you use. That is not the secret; the signature and the object's permissions are.
  • "Files are static, so serving is free." Egress is per byte and is usually the biggest number in a file feature's cost (Cost Engineering).

Operating it

How you see it in production
  • Separate file-serving traffic from API traffic in metrics. Mixed together, download duration destroys your API latency percentiles and hides both (Percentiles: Which One, and How Many Users Is That?).
  • Track CDN hit ratio per content type. A low ratio on public assets means the cache key or the headers are wrong, and it shows up on the egress bill first (Egress: Moving Data Costs Money, Not Just Storing It).
  • Count 302s issued versus storage fetches observed, where the provider exposes access logs. A large gap means URLs are being issued and not used, or reused far beyond their intended life.
  • Alert on any response that is both authorized and publicly cacheable. This can be asserted in tests and at the CDN configuration level, and it is worth doing both.
  • Monitor bandwidth out of your instances as a first-class metric — it is the number that says whether you are still an accidental file server (Network Signals: Is It the Network, or the Service on the Other End?).
What changes at 10x and 100x
  • At 10x reads, the difference between proxying and redirecting is the difference between scaling your API for bandwidth and not scaling it at all.
  • At 100x, cache hit ratio is the entire cost story for public content, and origin requests become a rounding error if key design and headers are right (CDN Architecture).
  • Private per-user content does not benefit from an edge cache in the same way, because each URL is unique. The win there is not moving bytes through your service, not caching them.
  • Egress is charged per byte leaving the provider and is usually the largest line on the bill for any read-heavy file feature — serving strategy affects it far more than storage strategy does.
What this costs
  • Redirecting to a signed URL removes bandwidth from your service and hands out a bearer credential you cannot revoke before expiry. Proxying keeps every check in your code and makes you a file server.
  • A CDN makes public content fast and cheap and adds a cache you must reason about — stale content, purge behaviour, and one more place a misconfiguration leaks data.
  • A separate origin for user content is the right security boundary and costs a second domain, its own TLS certificate, and CORS configuration for anything the browser fetches programmatically.
  • Short expiry on signed URLs is safer and breaks long downloads, slow connections and shared links, generating support load.
  • Immutable content-hashed keys make caching trivial and mean "replacing" a file is really creating a new one, with references to update.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe proxy / redirect / CDN split and the origin-separation argument hold everywhere.
  • CLOUD-SPECIFICThe details of private CDN-to-bucket access differ substantially: CloudFront uses an origin access control that signs requests to S3; Google Cloud CDN fronts a backend bucket with its own IAM binding and offers separately signed URLs and signed cookies; Azure Front Door and Azure CDN authorize to Blob Storage through managed identity or SAS. Signed *cookies* for granting access to a whole prefix exist on some of these and not others, and expiry limits differ. The mapping is not one-to-one (Mapping Services Across Cloud Providers).
  • PROTOCOL-SPECIFICRange requests, conditional requests (If-None-Match, If-Modified-Since) and content negotiation are HTTP mechanisms that storage and CDNs implement for you and that a hand-written proxy handler does not get for free. A proxy that ignores Range breaks video seeking with no error anywhere.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

OS & Networkingcdn-networking