Recently a coworker was tasked with adding a "file download" link to a web page. The file in question was being generated dynamically by the server, but we really wanted to make sure the file didn't get rendered in the user's tab. Here's how you can leverage HTTP headers to do that (and some alternative ways you shouldn't!)

Let's say you want to genreate a file server-side and send it back to the client's browser to download. If it's plain text, the browser will usually try to render it in a tab, without prompting the user what they want to do with it. The user could then save the page with ^s but that's less than ideal. Ideally, they would click a link/submit a form, and immediately be prompted to save the file to save the file to disk and even open it in a local application. If they elect to always perform that action with files of that kind, the download will start immediately.

The Default: Rendering in a Tab

As a baseline, here is some sample PHP for writing text to the client.


<?php
header('content-type: text/plain');
print("hello, world\n");

This code responds to all requests with a single header specifying the default plain text content type and then writes hello, world as the response body. Note that since text/plain is the default type, it can usually be omitted. I'm including it here for illustrative purposes.

You can listen for requests with this script using php(1) and send requests with curl(1).


$ nohup php -S localhost:8080 &
[1] 47647
appending output to nohup.out
$ curl -v localhost:8080
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Host: localhost:8080
< Date: Fri, 22 Oct 2021 21:39:37 GMT
< Connection: close
< X-Powered-By: PHP/7.4.24
< Content-type: text/plain;charset=UTF-8
<
hello, world
* Closing connection 0

Awesome. In the browser, the text gets rendered in the active tab.

Content-Disposition to the Rescue: Forcing a File Download Prompt

We can include a Content-Disposition header with the request to prompt for file download on the browser side. Here's some updated PHP:


<?php
header('content-type: text/plain');
header('content-disposition: attachment; filename=hello.txt');
print("hello, world\n");

With curl(1), we can see the header even though it just prints the text as usual.


$ curl -v localhost:8080 
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Host: localhost:8080
< Date: Fri, 22 Oct 2021 21:59:47 GMT
< Connection: close
< X-Powered-By: PHP/7.4.24
< Content-type: text/plain;charset=UTF-8
< content-disposition: attachment; filename=hello.txt
<
hello, world
* Closing connection 0

And on the browser side, the usesr is prompted to download hello.txt. The browser completely skips rendering the file in the tab (this isn't even a presented option!). Our MIME type (Text Document) is specified, and the user can optionally change their preference for what should be done with that type by default.

This is all it took to satisfy our primary use case. Now let's look at one of the dubious workarounds out there.

Please Don't Falsify Your Content-Type

This is a strange workaround that you may find elsewhere online (cough cough). It looks something like this:


<?php
header('content-type: application/my-random-fake-mime');
print("hello, world\n");

What this does is pretend that your content is an unknown type to the browser. This way it doesn't attempt to render the content and instead asks the user what to do with it. Not only is this a hack-y workaround, it's also broken. First of all, this MIME doesn't exist in the specification. So it's unknown, undocumented, and unnecessary.

The second way this is broken is it will probably bypass any of those user-defined settings that determine what to do with files of a plain text type. If the user wanted all text files to instantly download and open in NOTEPAD.EXE™, this would bypass that. Now the user has to add a rule for files of type my-random-fake-mime just for you.

The third (and last I'm going to talk about) way this is broken is it does not give the user a useful filename. They don't even get a file extension that they can do anything useful with. They'll have to guess by inspection, which makes for a poor user experience. See the screenshot for what this abomination looks like.

Wrap-Up

It's pretty easy once you know what to do. I'll quickly mention another hack online is to send multiple Content-Type headers to the client to "trick" the browser into downloading the file but with the correct MIME. Not only does this not work the way you would expect, it's also unecessary and goes against the HTTP spec., where only one Content-Type is expected with the response. If you really want to prove that, send as many headers with the content type as you want from the server and see how many of them curl(1) or the browser keeps around (hint: it's only the last one).

I should also mention this functionality is universal and does not apply only to PHP.