How HTTP Works

Response header

< Content-Disposition: attachment; filename="report.pdf"

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.

Reviewed 5 min readintermediate2 sourcesTry itMarkdown
Direction
Response
Category
Representation
Spec
RFC 6266
On this page

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 defines both disposition types.

How Content-Disposition Works

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

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

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:

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 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:

<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.

Server Implementation

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

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

add_header adds the disposition and 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:

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() 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:

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:

<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: 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:

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:

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

name identifies the form field. 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:

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

-F 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:

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 defines.

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

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) to free it. Keep in mind that 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.

Frequently asked questions

What is Content-Disposition?

Content-Disposition requests inline media-type handling or saving as an attachment. It can suggest a filename, but the browser controls the download UI.

How do I force a file download?

Use Content-Disposition: attachment; filename="file.pdf" to request saving the response. filename suggests a save name; the browser controls whether it shows a dialog or saves directly.

How do I handle special characters in filenames?

Use filename* with UTF-8 percent-encoded bytes and two apostrophe delimiters after UTF-8. Include an ASCII filename fallback alongside it.

What is the difference between inline and attachment?

inline requests normal media-type handling; attachment requests saving the response. The browser decides the download UI, and the suggested filename is advisory.

Sources

  1. MDN Web Docs: Content-Dispositiondeveloper.mozilla.org
  2. RFC 6266: Content-Dispositionrfc-editor.org

Keep going

Browse /search