Presigned URL Content-Type and caching gotchas

Presigned URL Content-Type and cache bugs: PNGs that S3 stored as GIFs, and expired R2 download links that browsers kept opening from cache. How to fix each.

By

Presigned URLs look finished once the first upload or download works. Then a file shows up with the wrong type, or an expired link keeps opening. This post covers two presigned URL bugs from my own projects: a presigned URL Content-Type problem that made S3 store PNG icons as GIFs, and Cloudflare R2 download links that browsers kept serving from cache after they had expired. Both come down to the same thing: a signed URL controls who can make a request, and you still have to decide what the request and the response carry.

How a presigned URL decides what it covers

A presigned URL is a normal S3 API request with the signature moved into the query string. Anyone holding the URL can make that request until it expires. The signature covers the method, the bucket and key, the expiry and whichever headers were included when the URL was signed.

That last part is where both bugs live. Headers that were signed must be sent exactly as signed, or the storage service rejects the request. Headers that were not signed can be anything the client chooses, and the service takes them as sent. Response headers work the other way round: unless you ask for them, you get whatever metadata the object was stored with.

Expired download links that still opened

Fotobank, a stock photo marketplace, serves original downloads to buyers through signed R2 URLs. Each link is short-lived on purpose: it is a paid file, and the link should stop working after it expires. R2 enforces that. An expired signed URL gets a 403 with an ExpiredRequest error.

The problem was the browser. The objects were served with no Cache-Control header and with a Last-Modified header. With that combination, browsers are allowed to guess how long a response stays fresh (HTTP calls this heuristic freshness) and they did cache the file. The HTTP cache guide on web.dev is blunt about this: leaving out Cache-Control does not disable caching, and browsers guess what fits the content instead. So a buyer who reopened an old download link got the file straight from the browser's disk cache, and the request never reached R2 to be refused.

For most static files, that heuristic is harmless. For a paid original behind an expiring link, it means the expiry only applies to people who never opened the link before.

Ask the storage service for the response headers you need

S3's GetObject accepts response-* query parameters that override headers on the response, including response-cache-control, response-content-type and response-content-disposition. They are part of the signed request, so the person holding the link can't change them. The GetObject API reference lists them, and R2 speaks the same S3 API, so the same SDK calls work against it.

On Fotobank, every original download link now goes through one signing function, and that function asks R2 for Cache-Control: private, no-store on the response. no-store tells the browser not to keep a copy at all, and private keeps shared caches out of it. Putting it in one function means no route can sign a download without it.

Here is the pattern I'd use for a download helper and an upload helper with the AWS SDK v3, which works for R2 and S3 alike:

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

const client = new S3Client({ region: "auto", endpoint: process.env.R2_ENDPOINT });

export function signDownload(bucket: string, key: string) {
  return getSignedUrl(
    client,
    new GetObjectCommand({ Bucket: bucket, Key: key, ResponseCacheControl: "private, no-store" }),
    { expiresIn: 300 },
  );
}

export function signPngUpload(bucket: string, key: string) {
  return getSignedUrl(
    client,
    new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: "image/png" }),
    { expiresIn: 300, signableHeaders: new Set(["content-type"]) },
  );
}

The expiry values are placeholders; pick what fits your flow.

A presigned URL Content-Type bug: PNGs stored as GIFs

The second bug was on the Buffdudes admin portal. Admins upload exercise icons, and the icon picker only accepts PNG files. The upload, though, sent Content-Type: image/gif. S3 stored every icon with that type and served every icon as a GIF.

The presigned URL for the upload signed only the host. Nothing in the signature said what type the file should be, so S3 accepted the header as sent and stored it as the object's metadata. Every later GET returned that type.

There is a detail in the AWS SDK v3 that makes this easy to miss. The S3 presigner adds content-type to its unsignable headers by default, so passing ContentType to PutObjectCommand alone does not bind the upload to that type. To sign it, pass signableHeaders: new Set(["content-type"]) in the presign options, as in the helper above. The R2 presigned URL docs give the same advice for uploads: sign the Content-Type and send the same value with the upload.

Once it is signed, the fix I'd make has two halves. The upload sends the file's real type, and the server signs the type it expects. A client that sends anything else gets a 403 and nothing is stored, which is a far better failure than a library of mislabelled files.

What I check on every presigned URL now

My checklist for any project that signs storage URLs:

  • One signing function per purpose. Downloads, uploads and previews each get one helper. Routes call the helper; nobody calls getSignedUrl directly. That is how a header policy stays consistent.
  • Sign the Content-Type on uploads. Decide the type on the server from what the feature accepts, sign it, and make the client send it.
  • Set response headers on downloads. Cache policy, and Content-Disposition when the file should save rather than open.
  • Short expiries for paid or private files, paired with no-store so the expiry means something.
  • Keep the bucket private. A signed URL is only a gate if the object can't be reached any other way.

The rest of my storage setup (validation, re-encoding, previews and private originals) is in my image processing pipeline with Sharp and S3. And if a route that signs URLs is no longer used, it is still attack surface, which is the subject of removing unused API endpoints safely. The same Buffdudes admin portal also had a slow dashboard, covered in moving aggregation into Postgres. Both products are on my projects page.

Does a presigned URL set the Content-Type of the uploaded file?

Only if you make it. S3 stores whatever Content-Type header the client sends with the upload. If that header is not part of the signature, the client can send any value and S3 will keep it. Sign the content type when you create the URL, and S3 will reject an upload that sends a different one.

The short version

A signed URL decides who can make a request until it expires. It does not decide what the request carries unless you sign those headers, and it does not stop browser caching unless you ask for cache headers on the response. Sign the Content-Type on uploads, request Cache-Control: private, no-store on private downloads, and put both rules in one signing function.

Want this kind of work on your product?

Get in touch