Learning & reference

HTTP headers that decide what happens to a file

Whether a file renders, downloads, resumes, caches or is refused outright is decided by a handful of response headers. Every claim on this page is demonstrable against files.hexaqa.com, which serves the test corpus with Range support and open CORS, so you can check each one with a single curl.

Render, download, or refuse

HeaderEffectNotes
Content-Type: application/pdfRenders inline if the browser can. The single most important header. See the MIME types reference for the correct value per extension.
Content-Type: text/html; charset=utf-8Renders, and fixes the encoding. Without the charset the browser guesses, and accented text becomes mojibake. See character encodings.
Content-Disposition: attachmentAlways downloads. The right way to force a download while keeping an accurate Content-Type.
Content-Type: application/octet-streamAlso downloads. Works, but discards type information. Prefer the header above.
X-Content-Type-Options: nosniffDisables type guessing. Essential for user-uploaded content: it is what stops a polyglot being treated as anything other than what you declared.
Content-Encoding: gzipCompressed in transit; the client inflates it. Not the same as Content-Type: application/gzip, which means the content is a .gz file. Setting both makes the client decompress once too often.

Filenames, including the ones that are not ASCII

This is where Content-Disposition gets interesting, and it is exactly what the filename corpus is built to exercise.

FormWhen to use it
attachment; filename="report.pdf" Plain ASCII names. Quote the value, and escape any quote inside it.
attachment; filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt Any non-ASCII name. The RFC 5987 encoded form: charset, a single quote, an empty language tag, another single quote, then percent-encoded UTF-8.
Both, ASCII first then filename* The correct answer in practice. Clients that understand filename* prefer it; older ones fall back to the ASCII name.

Two traps. Raw UTF-8 bytes in the plain filename parameter are not valid and every browser mangles them differently. And a filename is attacker-controlled input if it came from an upload: a newline in it is header injection, and ../ in it is a path traversal attempt against whatever consumes the download.

Range requests and resumable downloads

The corpus supports these, so these commands work as written.

HeaderDirectionMeaning
Accept-Ranges: bytesResponsePartial requests are supported. Without it a client must restart from zero.
Range: bytes=0-99RequestSend me the first 100 bytes.
206 Partial ContentResponseHere is the slice. A 200 here means the server ignored the range and sent everything.
Content-Range: bytes 0-99/1048576ResponseWhich slice, and the total size.
416 Range Not SatisfiableResponseThe range starts past the end of the file.
If-Range: <etag>RequestContinue only if the file has not changed; otherwise send the whole thing. This is what makes resuming safe.

Try it: curl -r 0-99 -o part.bin https://files.hexaqa.com/size/1mb.bin -D - returns 206 with Content-Range: bytes 0-99/1048576. Range support is also what makes video seeking work, so a media file served without it can only ever play from the start.

Caching and validators

HeaderWhat it meansWatch for
Cache-Control: public, max-age=31536000, immutable Cache for a year and never revalidate. Only safe when the URL's bytes can never change. Change them and caches keep serving the old body while reporting the new ETag, so a published checksum stops matching the download.
Cache-Control: no-storeDo not write this to any cache. What you want for anything user-specific. no-cache means something different: store it, but revalidate before reuse.
ETag: "abc123"A strong validator: these exact bytes. Required for range requests.
ETag: W/"abc123"A weak validator: equivalent, not identical. Quietly disables resumable downloads, because a partial request cannot use it.
Last-Modified / If-Modified-SinceTime-based revalidation. One-second resolution, so two changes in the same second are indistinguishable. Prefer an ETag.
304 Not ModifiedYour copy is current. Carries no body, so a client that expects one on every 200-or-304 breaks.
Vary: Accept-EncodingCache separately per encoding. Omitting it lets a cache serve a gzipped body to a client that did not ask for one.

CORS, and the header everyone forgets

Loading a URL directly is not a cross-origin request. Fetching it from JavaScript on another origin is, and the rules are different.

Access-Control-Allow-Origin

Without it the browser blocks the response from your script, even though the request reached the server and succeeded. The network tab shows a 200 and your code still fails.

Access-Control-Expose-Headers

The one that gets forgotten. By default JavaScript can read only a handful of response headers, so Content-Length is invisible and your download progress bar cannot work even though the download itself does.

The preflight

Anything beyond a simple request triggers an OPTIONS first. If that fails the real request is never sent, which is why the server log shows nothing at all.

* and credentials do not mix

Access-Control-Allow-Origin: * is rejected when the request carries credentials. Echo the specific origin instead, and add Vary: Origin so caches do not cross the wires.

Try it: the corpus sends Access-Control-Allow-Origin: * and exposes Content-Length, Content-Range, Content-Type, ETag and Accept-Ranges, so a browser-side fetch with a working progress bar is testable against it directly.

Security headers worth sending with files

HeaderWhy it matters for file delivery
X-Content-Type-Options: nosniff The single most valuable one here. Without it the browser may decide a file is HTML regardless of what you declared.
Content-Security-Policy: sandbox On a user-content origin, strips scripts and same-origin privileges from anything served, which neutralises an uploaded SVG or HTML page.
Cross-Origin-Resource-Policy Controls which origins may embed the resource at all.
A separate origin for user content Not a header, and more effective than all of them. A file served from a different domain cannot reach your cookies however it is interpreted.

Check any of it yourself

The generator echoes arbitrary status codes, headers and delays, so you can reproduce a client's behaviour without building a server.

Open the generator HTTP status codes → MIME types → Upload validation →