Back to All Posts

TIL: A Link’s Download Attribute Won’t “Just Work” for Cross-Origin Resources

I just realized that using the "download" attribute on HTML links works only for same-origin resources by default. Fortunately, the "fix" is pretty simple, as long as you have control over your server’s response headers.

I've been working on a small application allowing users to upload media to a Cloudflare R2 bucket. The high-level stack is pretty simple. It's got a client-side piece (React), a middle-tier service (Fastify on Node), with Cloudflare backing the uploads.

high-level diagram of my application stack

To no surprise, downloading that content is a part of the work too. When I began thinking through this, I was planning on streaming the objects through my Fastify server, and then straight to the users' browsers. But then I remembered: there's an very handy feature built into S3 (and therefore R2, which implements the same API).

That feature is presigned URLs. When you generate one and hand it out, that person gets time-limited permission to access the file. It'd be a far simpler means of getting a file out of my bucket and onto a user's device.

Triggering Downloads is... Quirky.

Using a presigned URL would save me a lot of time (and other resources), but I didn't want the user to click the link and navigate away from the page to access the object. So, I opted to use the download attribute available on the <a> tag. Click such a link, and the browser will prompt you to save it to your machine.

<a href="" download="file-name.mp3">
	Download Audio

I expected it to just work, but it didn't. Instead, it navigated to a new page, showing the resource directly in the browser.

I was confused, especially because some pretty good sources out there had no mention of why the browser might decide to honor or ignore the download request. Even MDN was pretty... vague about its reliability:

After some digging, that confusion moved toward clarity when I came across this in the HTML spec:

In cross-origin situations, the download attribute has to be combined with the `Content-Disposition` HTTP header [...] to avoid the user being warned of possibly nefarious activity.

And it was all solidified after seeing notes on this particular Chrome feature: browsers will ignore the download for cross-origin resources.

That explained my application's behavior. The presigned URLs came from R2 – not from my React application's domain. As a result, no download was triggered. The resource was simply treated as another navigation destination.

The Server Gets the Final Say

As you might've caught above, the solution to this is simple: set the Content-Disposition header on the resource to attachment. This will signal to the browser that it should download the resource – not navigate to it.

Fortunately, the AWS SDK allows you to set custom response headers when you generate a presigned URL. I'm using Node, so that meant using the ResponseContentDisposition property when retrieving the object.

import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const command = new GetObjectCommand({
	Bucket: "the-bucket-name",
	Key: "the-file-key",
+	// Customize the `Content-Disposition` header!
+	ResponseContentDisposition: `attachment; filename="file-name.mp3"`,

const signedUrl = getSignedUrl(S3, command, { expiresIn: 3600 });

This'll give behavioral instructions to the browser, as well as provide a default name for the file when it's downloaded. I get exactly the user experience I want, and I still don't need to mess with streaming anything through my server. Cheaper, easier, and more secure.

Is the Attribute Still Necessary?

It might be worth calling out that after I set that Content-Disposition header, the download attribute didn't have any impact on what happened when my link was clicked. It would now always trigger the download.

Still, I can see it being useful in keeping around. It'll better signal a link's purpose to other client-side code (maybe you'd like to style download links differently). So, despite it not bring strictly necessary, I'll probably keep using it. I like clarity.

Share Those TILs

There's still a small part of me kicking myself for taking so long to realize this pretty critical piece of the download attribute. But at the same time, I know I'm not the only one who has these moments, so I'm sure the feeling will quickly pass. If anything, let this be an encouragement to share even the smallest bits of learnings you come across out there. People like me might benefit from it.

Alex MacArthur is a software engineer working for Dave Ramsey in Nashville-ish, TN.
Soli Deo gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

  • Ivan

    I had the exact same problem, and this post helps a lot,
    thank you so much

    1 reply
    • Alex MacArthur

      Glad it was helpful, Ivan 👍

  • st

    Im using google cloud storage - as I understand it's all of the same machinery

    I have a gzip, I sign it, I set the metadata to have content disposition of "attachment"

    Still, the anchor just opens the link and no download is initiated.

  • Deez Nuts

    This was very useful. I was wondering why the download attribute wasn't working for me.

    1 reply
  • Rane Bowen

    Thank you for this - I've been struggling with file downloads from my cloudflare bucket for a while, and you genuinely saved my bacon.
    Thanks again!

    1 reply
  • Eduarte

    Thank you for this article, my problem was exactly the same, trying to make a download button from a presigned url from a s3 bucket. I had no idea why the page was loading the image. Now I realize that I need to study about CORS. The interesting thing though is that the guides I saw about using the download don't mention cors