# Content-Disposition Header

> Learn how the Content-Disposition header controls whether content displays inline or downloads as an attachment. Set custom filenames for file downloads.

Source: https://howhttpworks.com/headers/content-disposition
Last reviewed: 2026-10-05

> **TL;DR:** `Content-Disposition: attachment` tells the browser to download the response instead of displaying it; `inline` (the default behavior) displays it. Add `filename="report.pdf"` to suggest an ASCII save name, and `filename*=UTF-8''...` for a name with non-ASCII characters. The filename is only a suggestion, and receivers treat it as untrusted input.

## What is Content-Disposition?

On a response, `inline` asks the browser to handle the content normally for its media type, and `attachment` asks it to save the file. The browser still has the final say: user settings and its own handling decide whether you get a dialog or a direct save. [RFC 6266 §4.2](https://www.rfc-editor.org/rfc/rfc6266#section-4.2) defines both disposition types.

## How Content-Disposition Works

A download response looks like this (example headers, PDF body left out):

```http
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"
```

`Content-Type` still says what the file is. The disposition only changes how the browser handles it, so marking HTML as an attachment downloads HTML.

## Syntax

```http
Content-Disposition: inline
Content-Disposition: attachment; filename="report.pdf"
Content-Disposition: attachment; filename="report.pdf"; filename*=UTF-8''rapport-%C3%A9t%C3%A9.pdf
```

Those are two single apostrophes after `UTF-8`, with an empty language tag between them, not a double quote. Each parameter can appear once; repeating a name makes the whole field invalid.

## Common Examples

Quote an ASCII name that contains spaces:

```http
Content-Disposition: attachment; filename="monthly report.csv"
```

For international names, send an ASCII fallback in `filename` and the real name in `filename*`. Browsers that understand both use `filename*`.

## Real-World Scenarios

For a CSV export, send `Content-Type: text/csv` and a disposition that names the file. On a same-origin link, the HTML `download` attribute can override `inline` in Chrome and Firefox; [MDN's handling notes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition) have the details.

A preview endpoint and a download endpoint can serve identical PDF bytes. Send `inline` for the preview and `attachment` for the download, and keep `application/pdf` on both. The only difference is what you're asking the browser to do with it.

A plain link is all you need for a download:

```html
<a href="/report.pdf">Download the report</a>
```

The response headers decide the disposition. If you reach for the `<a download>` attribute instead, it only works for same-origin URLs and `blob:` or `data:` URLs, per [MDN's anchor reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#download).

## Server Implementation

In nginx, add this to a `server` block. The route returns a tiny CSV:

```nginx
location = /export.csv {
    default_type text/csv;
    add_header Content-Disposition 'attachment; filename="export.csv"';
    return 200 "status\nready\n";
}
```

[`add_header`](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) adds the disposition and [`default_type`](https://nginx.org/en/docs/http/ngx_http_core_module.html#default_type) sets the media type. Let `default_type` own `Content-Type`; adding another one with `add_header` gives you two.

In Express, let the download helpers build the filename parameter for you:

```javascript
app.get('/report.pdf', (req, res, next) => {
  res.download('/srv/reports/report.pdf', 'report.pdf', err => {
    if (err) next(err)
  })
})
app.get('/export.csv', (req, res) => {
  res.attachment('export.csv').send('status\nready\n')
})
```

[`res.download()`](https://expressjs.com/en/5x/api/response/#res.download) sends a file from disk. `res.attachment()` only sets the disposition and guesses a media type from the extension; you send the body yourself. Both paths here are fixed server paths, so a request parameter can't steer them anywhere. If you build a path from user input, validate it first. A download can fail after the headers are already out, which is why the callback passes the error to the app's error handler instead of trying to start a second response.

In Django, use [`FileResponse`](https://docs.djangoproject.com/en/6.0/ref/request-response/#fileresponse-objects):

```python
from django.http import FileResponse

def download_report(request):
    return FileResponse(
        open('/srv/reports/report.pdf', 'rb'),
        as_attachment=True,
        filename='report.pdf',
    )
```

Django closes the file for you once the response is sent. A `with` block would close it too early, before the response body is read. Both examples assume the file exists and that you've checked authorization before opening it.

With Apache and `mod_headers` loaded, a file-scoped rule marks CSV exports as attachments:

```apache
<Files "export.csv">
    Header set Content-Disposition "attachment; filename=export.csv"
</Files>
```

The filename can go unquoted here because it has no spaces or separator characters. If other directories have files with the same name, scope the rule to the export directory. The rule only sets the disposition; Apache's normal file handler still serves the body and media type.

## Getting Content-Disposition right

Treat filenames as advisory. Whenever your code receives one, from a download or an upload, strip path components, reject control characters and unsafe names, and decide whether a file is executable by something sturdier than its extension. RFC 6266 §4.3 lists these receiver-side precautions. When sending, keep raw user input out of response headers.

A value like `filename="../../report.pdf"` should never let a receiver write outside its download directory, and a suggested name should never decide whether an upload is safe to process. When sending, look the file up from an authorized record and pick the save name separately. The path on disk and the name the user sees do different jobs.

## Filename Encoding

`filename*` uses the extended parameter syntax, now specified in [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187#section-3.2.1): percent-encode the UTF-8 bytes. Keep percent escapes out of the plain `filename`, since browsers disagree about whether to decode them.

For a known name your application controls, you can build the extended value in JavaScript:

```javascript
const filename = 'rapport-été.pdf'
const encoded = encodeURIComponent(filename).replace(
  /['()*]/g,
  character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
)
res.set('Content-Disposition',
  `attachment; filename="rapport-ete.pdf"; filename*=UTF-8''${encoded}`)
```

`encodeURIComponent()` leaves a few characters unescaped that this parameter syntax doesn't allow literally, and the extra `replace` escapes them. Pick the ASCII fallback yourself. Encoding makes a name transmittable; it doesn't make it a safe name.

## Form Data Usage

Inside `multipart/form-data`, the same header describes one part of the body, not the whole response. Part headers look like this:

```http
Content-Disposition: form-data; name="upload"; filename="report.pdf"
Content-Type: application/pdf
```

[`name` identifies the form field](https://www.rfc-editor.org/rfc/rfc7578#section-4.2). RFC 7578 forbids `filename*` in form-data parts, so download syntax and upload-part syntax aren't interchangeable.

Let curl build the multipart body and boundary for you:

```bash
curl -v -F 'upload=@report.pdf;type=application/pdf' https://example.com/upload
```

[`-F`](https://curl.se/docs/manpage.html#--form) assembles the multipart body and the `Content-Type` boundary. Whatever receives the upload still has to validate it, because the sender supplies both the part's media type and its filename.

## Testing Content-Disposition

Check a GET response's headers without saving the body:

```bash
curl -sS -D - -o /dev/null https://example.com/export.csv
```

Then open the download URL in a browser. `fetch()` just retrieves a response; it never opens a save dialog by itself. If cross-origin JavaScript needs to read this header, expose it with `Access-Control-Expose-Headers: Content-Disposition` alongside the rest of your CORS policy, as the [Fetch Standard](https://fetch.spec.whatwg.org/#http-access-control-expose-headers) defines.

When the app needs to check a response before offering it as a download, validate the status and then create a blob URL:

```javascript
async function downloadReport() {
  const response = await fetch('/report.pdf')
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  const url = URL.createObjectURL(await response.blob())
  const link = document.createElement('a')
  link.href = url
  link.download = 'report.pdf'
  document.body.append(link)
  link.click()
  link.remove()
  return url
}
```

This uses a fixed filename rather than parsing `Content-Disposition`. Once the download UI is done with the URL, call [`URL.revokeObjectURL(url)`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) to free it. Keep in mind that [`response.blob()`](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) reads the entire body into memory first, so for big files a direct link that the browser streams to disk is the better choice.

## Related Headers

- [Content-Type](https://howhttpworks.com/headers/content-type)
- [Content-Length](https://howhttpworks.com/headers/content-length)
- [Content-Encoding](https://howhttpworks.com/headers/content-encoding)
