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 →

More from Learning

Guides and references for test data, file handling and AI evals. All free, no sign-up. See the full hub.

Synthetic data for AI evalsWhat you can download, what you buildRAG test corpusDocuments with extraction trapsJSONL for evalsThe format, and ten ways it breaksMock API for agentsRecords, pagination, failures on demandSafe test dataReserved domains, IPs, numbers & cardsSynthetic PIIFake people that reach nobodyTest card numbersEvery brand, declines, 3D SecureTest phone numbersReserved US, UK and Australian rangesReserved IP addressesDocumentation ranges and SSRF casesExample domains.test, .invalid, example.com, and trapsQA test stringsUnicode, emoji, injection & edge casesFilename compatibilityWhat breaks across Windows, Mac & LinuxFile systemsPOSIX, NTFS, APFS, ext4, SMB, NFS, NASFile signaturesMagic numbers that identify every formatCharacter encodingsUTF-8, UTF-16, BOM & normalizationUpload validationThe checklist, with a file for each checkMIME typesThe right Content-Type for every extensionHTTP status codesEvery code and when you actually see itDates & timesISO 8601, epoch, Excel serials, DSTRegex cheat sheetSyntax, flags and ready-made patternsGenerate files (CLI)Make test files on any OSCrypto & certificatesSSL/PEM, hashing, encryptionWeb app testingWhat to test, with a file for each caseDesktop app testingPaths, names, encodings and savingMobile app testingPickers, photos, flaky networksCLI testingArguments, pipes and exit codesLarge file upload testingDefault limits, 413s and exact sizes1 GB test file1 GB and 2 GB, with SHA-25610 GB test fileMake one locally in secondsDownload speed test files10 MB to 2 GB of random data