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
| Header | Effect | Notes |
|---|---|---|
Content-Type: application/pdf | Renders 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-8 | Renders, and fixes the encoding. | Without the charset the browser guesses, and accented text becomes mojibake. See character encodings. |
Content-Disposition: attachment | Always downloads. | The right way to force a download while keeping an accurate Content-Type. |
Content-Type: application/octet-stream | Also downloads. | Works, but discards type information. Prefer the header above. |
X-Content-Type-Options: nosniff | Disables type guessing. | Essential for user-uploaded content: it is what stops a polyglot being treated as anything other than what you declared. |
Content-Encoding: gzip | Compressed 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.
| Form | When 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.
| Header | Direction | Meaning |
|---|---|---|
Accept-Ranges: bytes | Response | Partial requests are supported. Without it a client must restart from zero. |
Range: bytes=0-99 | Request | Send me the first 100 bytes. |
206 Partial Content | Response | Here is the slice. A 200 here means the server ignored the range and sent everything. |
Content-Range: bytes 0-99/1048576 | Response | Which slice, and the total size. |
416 Range Not Satisfiable | Response | The range starts past the end of the file. |
If-Range: <etag> | Request | Continue 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
| Header | What it means | Watch 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-store | Do 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-Since | Time-based revalidation. | One-second resolution, so two changes in the same second are indistinguishable. Prefer an ETag. |
304 Not Modified | Your copy is current. | Carries no body, so a client that expects one on every 200-or-304 breaks. |
Vary: Accept-Encoding | Cache 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
| Header | Why 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 →