S3 File Downloads Instead of Opening? Fix Content-Type, Content-Disposition and "MIME Type binary/octet-stream" Errors for HTML, PDF, CSS and JavaScript
If an S3 file downloads instead of opening in the browser (an HTML page, a PDF or an image saves to Downloads instead of displaying), the object almost always has the wrong Content-Type, usually binary/octet-stream or application/octet-stream, or a Content-Disposition: attachment header. Browsers decide what to do with a file from those two headers, and S3 sends back whatever metadata was saved when the object was uploaded. It doesn't look at the file extension at download time. The same root cause produces the famous developer errors "Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of 'binary/octet-stream'" and "Refused to apply style ... because its MIME type is not a supported stylesheet MIME type". To fix an existing object, copy it onto itself with new metadata: aws s3 cp s3://bucket/index.html s3://bucket/index.html --content-type "text/html" --metadata-directive REPLACE. To fix future uploads, set the type when uploading: ContentType in boto3's ExtraArgs, --content-type in the CLI, or rely on the console and aws s3 cp, which guess it from the extension. If CloudFront sits in front, invalidate the cache afterward. Below: why it happens with each upload tool, the right type for every common file, fixing one object or a whole site, Content-Disposition for inline vs download, presigned URL overrides, gzip, and CloudFront.
Jake had finally moved his shop's website off an old shared host and onto S3 and CloudFront, with Ethan's help. The first deployment went perfectly; Ethan had uploaded it from the console. The second, which Jake did himself with a short Python script, did not. Visitors opening the home page got a file called index.html saved to their Downloads folder, the new price list PDF downloaded instead of opening, and the product gallery, a small JavaScript app, showed a blank white box. "Same files, same bucket," Jake said on the phone. "What did I break?" Ethan asked one question: "Did your script set a content type?"
New to S3 uploads or static sites? The cards below cover boto3 uploads, uploading folders, static website 403s, CloudFront caching and presigned URLs.
Why S3 files download instead of opening
When a browser receives a file, it does not look at the file name to decide what to do. It reads two HTTP response headers:
- Content-Type (also called the MIME type) says what the file is:
text/htmlfor a web page,application/pdffor a PDF,image/pngfor a PNG image,text/javascriptfor a script. If the browser knows how to display that type, it displays it. If the type is a generic "some bytes" type such asbinary/octet-streamorapplication/octet-stream, the browser cannot know what it is, so it offers to save it. - Content-Disposition, if present, can override that:
attachmenttells the browser to download the file even if it could display it, optionally with a suggested file name;inline(or no header) lets the browser display it.
S3 sends both headers from the object's metadata, which is fixed when the object is uploaded (or copied). S3 does not inspect the file or its extension when someone downloads it. So an index.html uploaded without a content type stays a generic binary object forever, until you change its metadata. That is the whole problem, and it explains why the same file works when uploaded one way and breaks when uploaded another.
Which upload paths set the type for you:
- The S3 console: sets Content-Type from the file extension when you upload. That is why Ethan's first deployment worked.
- AWS CLI
aws s3 cp,mvandsyncuploads: guess the type from the extension by default (unless you pass--no-guess-mime-type). - boto3 (
upload_file,put_object) and most SDK calls: skip guessing. Unless your code passes a content type, the object is stored with a generic binary type. That is what Jake's script did. - Third-party tools and CI plugins: vary; some guess, some skip it, some guess wrong for newer types such as
.mjs,.webp,.avifor.wasm.
Jake: "So S3 didn't look at the files at all. It just remembered whatever my script told it, which was nothing."
Ethan: "Exactly. And 'nothing' means 'unknown binary', which every browser saves to disk."
Does your upload tool set the type? Quick reference
| Tool or method | Sets Content-Type for you? | What you do |
|---|---|---|
| S3 console upload | Yes, from the extension | Check unusual extensions after upload |
| aws s3 cp / sync (local to S3) | Yes, guessed from the extension | Override with --content-type when the guess is wrong |
| aws s3 cp (S3 to S3, REPLACE) | No (CLI v2) | Always pass --content-type |
| aws s3api put-object | No | Pass --content-type |
| boto3 upload_file / put_object | No | ContentType in ExtraArgs or as a parameter |
| AWS SDK for JavaScript v3 | No | ContentType in the command input |
| AWS Tools for PowerShell (Write-S3Object) | Varies; set it yourself | Pass -ContentType |
| Terraform aws_s3_object | No | Set content_type, usually from a lookup map |
| AWS CDK BucketDeployment | Yes, from the extension | Override per file group if needed |
| Presigned PUT from a browser | Whatever the browser sends | Sign the URL with ContentType and send the same header |
If your tool is not in the table, upload one file of each type and run the check below. Five minutes of checking on the first deployment saves you an outage on the second.
Step 1: check what S3 is sending
Look at the object's stored metadata first, because that tells you whether the problem is in S3 or somewhere between S3 and your browser. With the AWS CLI:
aws s3api head-object --bucket shop-site --key index.html --query "{Type:ContentType,Disposition:ContentDisposition,Encoding:ContentEncoding,Cache:CacheControl}"
In the S3 console, open the object and look at Metadata on its Properties tab: the system-defined entries include Content-Type and, if set, Content-Disposition and Content-Encoding.
Then check what the browser actually receives, which can differ if CloudFront or a cache is in between. From PowerShell or a terminal:
curl -sI https://www.example-shop.com/index.html
HTTP/2 200
content-type: binary/octet-stream
x-cache: Hit from cloudfront
...
In the browser, open the developer tools (F12), go to the Network tab, reload, click the file and read Response Headers. If content-type is wrong there but right in S3, a cached copy is the problem (see CloudFront).
On Windows without curl, PowerShell shows you the same headers:
(Invoke-WebRequest -Uri "https://www.example-shop.com/index.html" -Method Head).Headers["Content-Type"]
What you are looking for: text/html for pages, text/css for stylesheets, text/javascript for scripts, and no Content-Disposition: attachment on anything you want displayed. If you see binary/octet-stream or application/octet-stream on any of them, you have found your problem, and the next two steps fix it.
The right Content-Type for common files
| Extension | Content-Type | Notes |
|---|---|---|
| .html, .htm | text/html; charset=utf-8 | Without the charset, some characters may display wrongly |
| .css | text/css | Browsers refuse stylesheets with other types |
| .js, .mjs | text/javascript | application/javascript also works; module scripts reject anything else |
| .json | application/json | Also .map source maps |
| application/pdf | Opens in the browser's PDF viewer unless disposition is attachment | |
| .png / .jpg, .jpeg / .gif | image/png / image/jpeg / image/gif | |
| .webp / .avif | image/webp / image/avif | Older tools may not know these |
| .svg | image/svg+xml | Shows as text or downloads with other types |
| .ico | image/x-icon | Favicon |
| .woff2 / .woff | font/woff2 / font/woff | Web fonts (also need CORS when loaded from another domain) |
| .wasm | application/wasm | Required for WebAssembly streaming compilation |
| .mp4 / .webm / .mp3 | video/mp4 / video/webm / audio/mpeg | Players stream these inline |
| .txt / .csv | text/plain; charset=utf-8 / text/csv | Browsers usually download CSV even with the right type |
| .xml | application/xml | Sitemaps and feeds |
| .zip / .exe / .msi | application/zip / application/octet-stream | These should download; add attachment disposition for a clean file name |
Step 2: fix the Content-Type of existing objects
S3 objects are immutable: you cannot edit metadata in place. "Changing" metadata means copying the object onto itself with new metadata. The copy keeps the same key, so URLs stay the same. In a versioned bucket, it creates a new version (the old one becomes noncurrent).
One object, with the AWS CLI
aws s3 cp s3://shop-site/index.html s3://shop-site/index.html --content-type "text/html; charset=utf-8" --metadata-directive REPLACE --cache-control "max-age=300"
--metadata-directive REPLACE tells S3 to use the metadata you supply instead of copying the old metadata. That has a side effect: everything not on the command line is dropped, including Cache-Control, Content-Disposition and any custom x-amz-meta- metadata. Add back whatever you need in the same command (--cache-control, --content-disposition, --metadata key=value). Also note that for copies within S3, the AWS CLI version 2 does not guess the type from the extension, so always pass --content-type explicitly.
One object, in the console
- Open the object in the S3 console and choose Actions > Edit metadata (or select it in the list first).
- Under Metadata, find the system-defined Content-Type entry, or choose Add metadata, type System defined, key Content-Type.
- Enter the right value, for example
text/html, and choose Save changes.
Behind the scenes, the console performs the same copy. You can select several objects and edit their metadata in one go, which is handy for a handful of files with the same type.
One object, with Python (boto3)
import boto3
s3 = boto3.client("s3")
s3.copy_object(
Bucket="shop-site",
Key="docs/price-list.pdf",
CopySource={"Bucket": "shop-site", "Key": "docs/price-list.pdf"},
ContentType="application/pdf",
ContentDisposition="inline",
CacheControl="max-age=3600",
MetadataDirective="REPLACE",
)
copy_object works for objects up to 5 GB; for larger objects, use s3.copy() (the managed transfer) with ExtraArgs containing the same metadata and "MetadataDirective": "REPLACE".
If the bucket uses SSE-KMS encryption, the identity doing the copy needs kms:Decrypt and kms:GenerateDataKey on the key, as for any copy.
Fix a whole website or folder at once
For a static site with hundreds of files of many types, fix each type in one pass with --recursive and include and exclude filters. Run one command per type:
aws s3 cp s3://shop-site/ s3://shop-site/ --recursive --exclude "*" --include "*.html" --content-type "text/html; charset=utf-8" --metadata-directive REPLACE
aws s3 cp s3://shop-site/ s3://shop-site/ --recursive --exclude "*" --include "*.css" --content-type "text/css" --metadata-directive REPLACE
aws s3 cp s3://shop-site/ s3://shop-site/ --recursive --exclude "*" --include "*.js" --include "*.mjs" --content-type "text/javascript" --metadata-directive REPLACE
aws s3 cp s3://shop-site/ s3://shop-site/ --recursive --exclude "*" --include "*.svg" --content-type "image/svg+xml" --metadata-directive REPLACE
aws s3 cp s3://shop-site/ s3://shop-site/ --recursive --exclude "*" --include "*.pdf" --content-type "application/pdf" --metadata-directive REPLACE
The --exclude "*" must come first; then each --include adds a pattern back. On PowerShell, the same commands work unchanged; in Command Prompt, the double quotes are also fine.
A faster and cleaner alternative, if you still have the site files locally, is simply to upload again with the CLI, which guesses types from extensions:
aws s3 cp .\site\ s3://shop-site/ --recursive
Use cp --recursive here rather than sync: aws s3 sync skips files whose size and timestamp have not changed, so it will not re-upload (and re-type) files that are already there. Our folder upload guide covers sync, cp and the console in more detail.
One --content-type on a mixed sync labels everything the same
A common mistake is aws s3 sync .\site\ s3://shop-site/ --content-type text/html, meant to fix the HTML files, which instead labels every CSS file, image and script as HTML. --content-type applies to every file in the command; split uploads by type with include and exclude filters when you set it explicitly.
Find every object with the wrong type: an audit script
Before and after a fix, you want a list of exactly which objects are wrong. S3 Inventory reports do not include Content-Type, so the reliable way is a HEAD request per object. This script checks the extensions that break websites and prints only the mismatches:
import boto3
BUCKET = "shop-site"
EXPECTED = {
"html": ("text/html",),
"css": ("text/css",),
"js": ("text/javascript", "application/javascript"),
"mjs": ("text/javascript", "application/javascript"),
"svg": ("image/svg+xml",),
"pdf": ("application/pdf",),
"wasm": ("application/wasm",),
}
s3 = boto3.client("s3")
paginator = s3.get_paginator("list_objects_v2")
bad = 0
for page in paginator.paginate(Bucket=BUCKET):
for obj in page.get("Contents", []):
key = obj["Key"]
ext = key.rsplit(".", 1).pop().lower() if "." in key else ""
wanted = EXPECTED.get(ext)
if not wanted:
continue
actual = s3.head_object(Bucket=BUCKET, Key=key).get("ContentType", "")
if not actual.startswith(wanted):
bad += 1
print(f"{key}: {actual!r}, expected {wanted}")
print(f"{bad} objects with the wrong type")
HEAD requests are billed at the GET rate, a fraction of a cent per thousand, so even a site with tens of thousands of files costs pennies to audit. Run it after every deployment pipeline change and you will catch a broken uploader before your visitors do.
Set Cache-Control while you are at it
Because --metadata-directive REPLACE rewrites all metadata, a content type fix is the natural moment to set caching properly too. A pattern that works for most static sites:
- HTML pages:
Cache-Control: max-age=300(orno-cache, which lets browsers and CloudFront revalidate every time), so content updates show up quickly. - Fingerprinted assets (
app.3f9a1c.js,styles.8b2e.css, which build tools name with a content hash):Cache-Control: max-age=31536000, immutable, because a changed file gets a new name. - Images and PDFs without fingerprints: something in between, such as a day (
max-age=86400).
CloudFront respects these values within the minimum and maximum TTLs of your cache policy, so the right Cache-Control on the object both speeds up your site and shortens the time a wrong header can linger in caches.
Deploying from GitHub Actions or another CI system
Most CI deployments call the AWS CLI, which guesses types from extensions on upload, so the usual pattern works: build, then aws s3 sync ./dist s3://shop-site --delete. Two refinements keep types and caching right on every run:
- name: Upload fingerprinted assets with long caching
run: aws s3 sync ./dist/assets s3://shop-site/assets --cache-control "max-age=31536000, immutable"
- name: Upload HTML with short caching and an explicit type
run: aws s3 sync ./dist s3://shop-site --exclude "assets/*" --include "*.html" --content-type "text/html; charset=utf-8" --cache-control "max-age=300"
- name: Upload everything else
run: aws s3 sync ./dist s3://shop-site --exclude "assets/*" --exclude "*.html" --delete
- name: Invalidate HTML in CloudFront
run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} --paths "/*.html" "/"
Linux CI runners use the system's MIME database, which knows common web types; Windows runners read the registry, so on Windows agents add explicit --content-type values for scripts and stylesheets, or switch the deploy job to a Linux runner.
Jake: "So once the pipeline does it, I never think about content types again?"
Ethan: "Until you add a new file type. Then you run the audit script once and you're done."
Millions of objects
For very large buckets, an S3 Batch Operations Copy job can rewrite metadata across millions of objects with a completion report of failures; filter its manifest by extension first, and run one job per content type.
Step 3: set the right type when you upload, by tool
boto3 (Python)
upload_file takes the type in ExtraArgs; put_object takes it as a parameter. Python's built-in mimetypes module guesses from the extension:
import mimetypes
from pathlib import Path
import boto3
s3 = boto3.client("s3")
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("text/javascript", ".mjs")
mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("application/wasm", ".wasm")
site = Path("site")
for path in site.rglob("*"):
if path.is_file():
key = path.relative_to(site).as_posix()
content_type, _ = mimetypes.guess_type(path.name)
s3.upload_file(
str(path),
"shop-site",
key,
ExtraArgs={"ContentType": content_type or "application/octet-stream"},
)
The add_type lines matter: on Windows, mimetypes reads file associations from the registry, and a machine where some program changed the .js or .css association can return text/plain, which breaks scripts and stylesheets. Declaring the important types yourself makes the script give the same answer on every machine. This is the change Jake made to his script. Our upload_file vs put_object guide covers the other ExtraArgs.
AWS SDK for JavaScript v3
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { readFile } from "node:fs/promises";
const s3 = new S3Client({ region: "us-east-1" });
await s3.send(new PutObjectCommand({
Bucket: "shop-site",
Key: "index.html",
Body: await readFile("site/index.html"),
ContentType: "text/html; charset=utf-8",
CacheControl: "max-age=300",
}));
Packages such as mime-types look up the type from the file name if you upload many kinds of files.
AWS Tools for PowerShell
Write-S3Object -BucketName shop-site -Key "docs/price-list.pdf" -File .\price-list.pdf -ContentType "application/pdf"
AWS CLI
aws s3 cp and sync guess types for uploads from local files, so plain uploads usually come out right. Check unusual extensions with head-object after the first upload, and pass --content-type for files the guess gets wrong. aws s3api put-object is the low-level call: it does not guess, so pass --content-type every time.
Terraform: aws_s3_object with a content type map
Terraform's aws_s3_object stores whatever content_type you give it, and nothing if you give it nothing. When you upload a whole site with fileset, look the type up from the extension with a map:
locals {
mime_types = {
html = "text/html; charset=utf-8"
css = "text/css"
js = "text/javascript"
mjs = "text/javascript"
json = "application/json"
svg = "image/svg+xml"
png = "image/png"
jpg = "image/jpeg"
webp = "image/webp"
ico = "image/x-icon"
pdf = "application/pdf"
woff2 = "font/woff2"
wasm = "application/wasm"
}
}
resource "aws_s3_object" "site" {
for_each = fileset("${path.module}/site", "**")
bucket = aws_s3_bucket.site.id
key = each.value
source = "${path.module}/site/${each.value}"
etag = filemd5("${path.module}/site/${each.value}")
content_type = lookup(local.mime_types, lower(element(split(".", each.value), length(split(".", each.value)) - 1)), "application/octet-stream")
}
The etag line makes Terraform re-upload a file when its content changes. For larger sites, a deploy step with aws s3 sync after terraform apply is usually simpler and faster than managing every file as a Terraform resource.
AWS CDK: BucketDeployment
The CDK's BucketDeployment construct uploads a local folder and sets content types from file extensions for you, which is one reason CDK-deployed sites rarely have this problem. If you need a specific type or Cache-Control for a group of files, use two deployments with different include patterns and set contentType or cacheControl on each.
Static site generators and frontend frameworks
Hugo, Jekyll, Gatsby, Astro, Next.js static exports, Vite and Angular builds all produce an ordinary folder of HTML, CSS, JavaScript and assets, and how the types come out depends entirely on how you upload that folder, not on the framework. If you deploy with the AWS CLI, the types are guessed correctly for nearly every file; if you deploy with a custom script or a plugin, check that it sets types. Two framework-specific details are worth knowing: Vite and Angular produce ES module scripts, so a wrong script type breaks the whole app rather than one feature; and some frameworks produce extensionless files or clean-URL folders, which need text/html set explicitly and an index-document rewrite at CloudFront, as described in the static website section below.
Upload forms and presigned POST
When browsers upload files directly to S3 (with a presigned URL or a presigned POST), the content type comes from the request. For presigned PUT URLs, include ContentType when you generate the URL and send the same Content-Type header from the browser; a mismatch causes a SignatureDoesNotMatch error. For presigned POST, add a Content-Type field and a matching condition in the policy. Our presigned URL guide shows both.
"Expected a JavaScript module script" and other MIME type errors
For pages and PDFs, a wrong type means a download. For scripts and stylesheets loaded by a page, browsers are stricter, and a wrong type means the resource is refused, which usually shows up as a blank page or an unstyled site. Open the browser console (F12) to see the message:
- "Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "binary/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec." Modern build tools (Vite, Angular, recent React setups) load code as ES modules, which browsers accept only with a JavaScript content type. Fix:
text/javascripton every.jsand.mjsfile. - "Refused to apply style from '...' because its MIME type ('binary/octet-stream') is not a supported stylesheet MIME type, and strict MIME checking is enabled." Fix:
text/csson every.cssfile. - "Refused to execute script from '...' because its MIME type ('text/plain') is not executable": usually a script uploaded from a Windows machine whose registry maps
.jstotext/plain, or a server sendingX-Content-Type-Options: nosniffwith a wrong type. Fix the type. - "Incorrect response MIME type. Expected 'application/wasm'": WebAssembly streaming compilation needs
application/wasm. - The MIME type is "text/html" for a .js or .css file: this one is different. The file is not there, and an error page (often your single-page app's index.html, served for every 404 or 403 by a CloudFront custom error response) came back instead. Check the path and that the file was uploaded.
Jake: "The blank gallery was the script being refused, not missing?"
Ethan: "Refused. The browser downloaded it, saw 'unknown binary', and wouldn't run it. Fix the type, clear the cache, and it's back."
The Windows registry problem, and how to check it
If your uploads come from a Windows PC or a Windows build agent, you may meet a quirk that is hard to spot: Python's mimetypes module (which the AWS CLI also uses for its guesses) reads file associations from the Windows registry. Installing some editors or tools changes the registry's Content Type value for extensions such as .js to text/plain, and from then on every upload from that machine labels scripts as plain text. You can see what your machine says in PowerShell:
Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\.js" -Name "Content Type" -ErrorAction SilentlyContinue
Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\.css" -Name "Content Type" -ErrorAction SilentlyContinue
If .js shows text/plain, either pass explicit types when you upload (the safe choice for scripts and pipelines) or, if you are comfortable editing the registry and it is your own machine, set the value to text/javascript. The mimetypes.add_type lines in the boto3 example above make your script immune to this, whatever the registry says.
Garbled characters: the charset part of Content-Type
A related problem that also comes from metadata: the page or text file opens, but accented letters and symbols look wrong. "cafΓ©" shows as "cafΓ©", the euro sign as "Γ’‚¬", or curly quotes as strange sequences. The file is fine; the browser is decoding UTF-8 bytes with the wrong character set because the Content-Type did not say which one to use.
- Add the charset to text types:
text/html; charset=utf-8,text/plain; charset=utf-8,text/csv; charset=utf-8. Use the same copy-onto-itself command as for any other metadata fix. - Also declare it in HTML with
<meta charset="utf-8">as the first thing in<head>, which helps when the header is missing. - Check the file itself if the problem persists: a file saved by an old Windows editor in a legacy encoding (such as Windows-1252) is not UTF-8, and declaring UTF-8 makes it worse. Re-save it as UTF-8, then upload.
If you are serving CSV files that people open in Excel, Excel on Windows often guesses the encoding wrongly for UTF-8 files without a byte-order mark; that is Excel's behavior, not S3's, and adding a BOM to the file (or importing through Excel's Data menu) fixes it.
Why it works in one browser and not another: MIME sniffing
You may see a file display in one browser and download in another, or a script run in one and fail in another. That comes from MIME sniffing: when the declared type is missing or generic, some browsers look at the first bytes of the file and guess. A file starting with <!DOCTYPE html> may be shown as a page; a PNG signature may be shown as an image. Guessing differs between browsers and versions, and it is deliberately switched off in cases where it would be unsafe:
- Module scripts and stylesheets are always checked strictly; a wrong type is refused in every modern browser.
- The
X-Content-Type-Options: nosniffheader turns sniffing off entirely, so every file must carry its correct type. Many security baselines, and CloudFront's managed security headers response policy, add it. binary/octet-streamandapplication/octet-streamusually mean "download" without sniffing for display.
So a site that "works on my machine" with wrong types is relying on one browser's guesses. Set the correct types and every browser behaves the same.
The cost of fixing metadata in a versioned bucket
Every metadata fix is a copy, and in a versioned bucket every copy is a new version. The old version, with the wrong headers, stays as a noncurrent version and keeps billing for storage until a lifecycle rule expires it. For a website of a few hundred megabytes that is pennies; for a bucket of large videos or datasets, add a lifecycle rule that expires noncurrent versions after a short period, or accept the temporary doubling of storage. The copy requests themselves are billed as PUT requests.
Images, PDFs and videos that will not display
Media files have their own small traps, and they are worth checking if pictures or videos on your site download or show as broken icons:
- image/jpg is not a real type. The registered type for JPEG is
image/jpeg. Most browsers tolerateimage/jpg, but some tools and email clients reject it; useimage/jpeg. - HEIC photos from iPhones (
.heic) cannot be displayed by most browsers whatever the type, so they download. Convert them to JPEG or WebP when your users upload them. - SVG with the wrong type shows as raw XML text or downloads. Use
image/svg+xml. - PDFs display in the browser's viewer with
application/pdfand no attachment disposition. If you want them to download, addContent-Disposition: attachmentrather than changing the type. - Videos and audio need their real types (
video/mp4,audio/mpeg) for the browser's player to start. S3 supports range requests, so players can seek and stream without downloading the whole file, as long as the type is right. - CSV and text exports usually download in most browsers even with
text/csv; that is the browser's choice, not a fault in S3.
Files served through Lambda and API Gateway
If your application serves S3 files through an API rather than straight from S3 or CloudFront, the Content-Type comes from your code, not from S3, and the API layer can change it:
- Return the object's own type. When your Lambda function reads an object with GetObject, pass its
ContentTypethrough as the response header instead of hard-coding one. - API Gateway REST APIs and binary files: to return images, PDFs or other binary content, list the types (or
*/*) under binary media types in the API's settings, return the body base64-encoded withisBase64Encoded: truefrom Lambda, and redeploy the stage. Otherwise you get corrupted files or the wrong type. - Better for large files: return a redirect to a presigned URL (with
ResponseContentTypeandResponseContentDispositionif you need them) and let the browser download from S3 directly. That sidesteps API Gateway's payload limit and Lambda's response size limit entirely.
Security: user uploads and Content-Type
Content-Type is also a security setting, and it matters most when your users upload files that other users open. If someone uploads an HTML file (or an SVG containing script) and your bucket serves it as text/html or image/svg+xml from your own domain, the browser runs any script inside it as if your site wrote it, which is a cross-site scripting hole.
- Serve user uploads from a separate domain (a different bucket and CloudFront distribution, or presigned URLs on the S3 domain), rather than from your main site's domain.
- Set the type yourself from a short allowlist (images, PDFs) when you store an upload, instead of trusting the type the browser sent.
- Force download with
Content-Disposition: attachmentfor anything that is not on the allowlist. - Send X-Content-Type-Options: nosniff (a CloudFront response headers policy can add it), which stops browsers from guessing a more dangerous type than the one you set.
Jake: "My customers only upload photos for custom prints."
Ethan: "Then store them with an image type you choose, from your own list, and serve them from the uploads bucket, not the shop's domain. It costs nothing and closes the door."
Content-Disposition: open in the browser, or force a download
Sometimes you want the opposite of Jake's problem: a file that the browser would display, such as a PDF invoice or a CSV export, should download with a sensible name. Content-Disposition controls that:
Content-Disposition: inline(or no header): display in the browser if it can.Content-Disposition: attachment: always download.Content-Disposition: attachment; filename="invoice-1042.pdf": download with that file name, whatever the key is.
Set it at upload (ContentDisposition in boto3 and the JavaScript SDK, --content-disposition in the CLI) or with the same copy-onto-itself command used for Content-Type. For non-ASCII file names, use the filename*=UTF-8'' form with the name percent-encoded, so every browser gets it right.
If a file downloads even though its Content-Type is correct, check for an attachment disposition you did not mean to set; some upload tools and frameworks add it by default for "file" uploads.
Per-request overrides with presigned URLs
A stored disposition is not your only option. For signed requests, such as presigned URLs, S3 accepts query parameters that override response headers for that request only: response-content-type, response-content-disposition, response-cache-control and a few others. That lets one stored object open inline in one place and download with a custom name in another:
url = s3.generate_presigned_url(
"get_object",
Params={
"Bucket": "shop-private",
"Key": "invoices/2026/1042.pdf",
"ResponseContentType": "application/pdf",
"ResponseContentDisposition": 'attachment; filename="invoice-1042.pdf"',
},
ExpiresIn=3600,
)
These overrides work only on signed requests, not anonymous (public) ones; for public objects, set the metadata on the object itself. If a presigned link stops working with "Request has expired," see our expired presigned URL guide.
Pre-compressed files: Content-Encoding
Some build pipelines compress files before upload (app.js stored as gzip bytes) to save bandwidth. Then two headers must be right: Content-Type: text/javascript (what the file is) and Content-Encoding: gzip (how it is compressed). Without Content-Encoding, browsers receive compressed bytes and leave them packed, and you see garbage, a download, or a syntax error in the console.
aws s3 cp .\dist\app.js.gz s3://shop-site/app.js --content-type "text/javascript" --content-encoding "gzip" --cache-control "max-age=31536000, immutable"
A simpler option with CloudFront is to upload files uncompressed and turn on Compress objects automatically in the cache behavior; CloudFront then compresses text files on the fly for browsers that accept it, and S3 metadata stays simple.
If you do pre-compress, keep the original file name (app.js, not app.js.gz) as the key, so your HTML references stay the same, and make sure every client that fetches the file accepts gzip; browsers do, but a few scripts and older tools that read the file directly from S3 receive the compressed bytes and must decompress them themselves. Brotli-compressed files work the same way with Content-Encoding: br.
CloudFront in front of S3: why the fix does not show up
If you fixed the metadata in S3 but browsers still download the file, CloudFront (or the browser) is serving a cached copy with the old headers. CloudFront caches the response headers along with the body until the cached object expires.
- Confirm the fix in S3 with
head-object. - Create an invalidation for the affected paths:
aws cloudfront create-invalidation --distribution-id E1A2B3C4D5E6F7 --paths "/index.html" "/assets/*". The first 1,000 invalidation paths each month are free. - Wait for the invalidation to complete (usually a minute or two), then test with
curl -sIand a hard refresh in the browser (Ctrl+F5), or a private window.
Our CloudFront not updating guide covers invalidations, cache keys and versioned file names, which make most invalidations unnecessary in the first place.
A CloudFront Function on the viewer response can also set or correct headers at the edge, for example adding Content-Type: text/javascript for every path ending in .mjs. It is a useful safety net for a site that many people deploy to, but fixing the metadata at upload is the real fix: every other client that reads the object directly from S3 still gets the stored headers.
This is a different problem from the S3 page that says "This XML file does not appear to have any style information": that is an S3 error response (Access Denied or NoSuchKey) displayed by the browser, covered in our XML style information guide.
Static website hosting and index documents
Two more ways a static site on S3 ends up downloading pages:
- Folders without an index document. With S3 static website hosting, a request for
/products/returnsproducts/index.htmlif you set an index document. Through CloudFront with an S3 REST origin (the common setup with origin access control), only the root object is handled by the distribution's default root object; subfolder requests need a small CloudFront Function that appendsindex.html. Without it, a request for/products/may return a zero-byte "folder" object with a generic type, which downloads as a nameless file. - Extensionless keys. Some sites store pages as
productsrather thanproducts.htmlfor clean URLs. Upload tools cannot guess a type for a key without an extension, so settext/htmlon those keys explicitly.
Our static website 403 guide covers bucket policies and the website endpoint versus REST endpoint differences.
Prevent it: a deployment checklist
- Deploy with a tool that sets types: the AWS CLI's
cporsync, a framework's deploy command, or your own script with explicitContentTypevalues. - Declare types for newer extensions (
.mjs,.webp,.avif,.wasm,.woff2) in your script instead of trusting the machine's defaults. - Check one file of each type after the first deployment with
head-objectorcurl -sI. - Set Cache-Control at the same time (short for HTML, long for fingerprinted assets), since metadata replacement drops it if you forget.
- Invalidate CloudFront paths after metadata fixes, or use fingerprinted file names so new files always get a fresh URL.
- Add a tiny test to your pipeline that fetches the home page, one script and one stylesheet and fails the deployment if any Content-Type is wrong.
Frequently asked questions
Why does my S3 file download instead of opening in the browser?
The object's stored Content-Type is generic (binary/octet-stream or application/octet-stream) or it has Content-Disposition: attachment. Set the correct Content-Type, such as text/html or application/pdf, and remove the attachment disposition.
How do I change the Content-Type of an existing S3 object?
Copy it onto itself with new metadata: aws s3 cp s3://bucket/key s3://bucket/key --content-type TYPE --metadata-directive REPLACE, or use Actions, Edit metadata in the S3 console.
What is binary/octet-stream in S3?
A generic type meaning unknown binary data. Objects uploaded without a content type end up with a generic binary type, and browsers download them instead of displaying them.
Does boto3 set the Content-Type automatically?
No. upload_file and put_object store no type unless you give one; pass ContentType (in ExtraArgs for upload_file). Python's mimetypes module can guess it from the extension.
Does the AWS CLI set the Content-Type?
For uploads from local files, aws s3 cp and sync guess the type from the extension unless you pass --no-guess-mime-type. For S3-to-S3 copies, pass --content-type explicitly.
How do I fix "Expected a JavaScript module script but the server responded with a MIME type of binary/octet-stream"?
Set Content-Type text/javascript on every .js and .mjs object (copy with --metadata-directive REPLACE or re-upload), then invalidate CloudFront.
How do I fix "is not a supported stylesheet MIME type" for S3?
Set Content-Type text/css on your .css objects, re-upload or copy them onto themselves, and clear any CloudFront cache.
How do I make an S3 PDF open in the browser instead of downloading?
Give it Content-Type application/pdf and either no Content-Disposition or Content-Disposition: inline.
How do I force an S3 file to download with a specific name?
Set Content-Disposition: attachment; filename="name.ext" on the object, or add response-content-disposition to a presigned URL for that request only.
Why did --metadata-directive REPLACE remove my Cache-Control?
REPLACE replaces all metadata with what you supply. Include --cache-control, --content-disposition and any --metadata values in the same command.
I fixed the Content-Type but the browser still downloads the file. Why?
A cached copy with the old headers: create a CloudFront invalidation for the paths, then test in a private window or with curl -sI.
Can I set the Content-Type for a whole bucket?
Not as a bucket setting; Content-Type is per object. Fix existing objects with recursive copies filtered by extension, and set types at upload.
What Content-Type should SVG files have in S3?
image/svg+xml. With other types, browsers show the SVG as text or download it.
Does S3 static website hosting fix content types?
No. Both the website endpoint and the REST endpoint return the Content-Type stored in each object's metadata.
Why is my .js file served with text/html?
The file was not found and an error page (often index.html from a CloudFront custom error response) came back instead. Check the path and that the file exists in the bucket.
Why does my favicon or web font not load from S3?
Check its type first: favicons need image/x-icon (or image/png for PNG icons) and fonts font/woff2 or font/woff. Fonts loaded from another domain also need a CORS configuration on the bucket.
What is the Content-Type for WebAssembly files?
application/wasm. WebAssembly streaming compilation fails with other types.
For readers preparing for an AWS certification
We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.
Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03) • CloudOps Engineer – Associate (SOA-C03).
Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:
1. A static website hosted on S3 downloads index.html instead of displaying it. What is the most likely cause?
The object was uploaded without the correct Content-Type (text/html), so S3 returns a generic binary type.
2. How do you change the Content-Type of an existing S3 object?
Copy the object onto itself with the new Content-Type and the REPLACE metadata directive.
3. A presigned URL must make a PDF download with a specific file name, while the same object opens inline elsewhere. What should be used?
The response-content-disposition override in the presigned GET URL.
4. After fixing object metadata in S3, CloudFront still serves the old Content-Type. What should be done?
Create a CloudFront invalidation for the affected paths.
5. Which boto3 setting stores the correct Content-Type when uploading a file with upload_file?
ExtraArgs={"ContentType": "..."}.
6. Users upload files that other users open from the company's domain. Which practice reduces cross-site scripting risk?
Set Content-Type from an allowlist, force Content-Disposition: attachment for other types, and serve uploads from a separate domain.
7. Pre-compressed JavaScript files stored in S3 appear as garbage in browsers. Which metadata is missing?
Content-Encoding: gzip (with the correct Content-Type).
Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.
Jake's fix took ten minutes: the five recursive copy commands to repair what was already in the bucket, one CloudFront invalidation, and the mimetypes lines added to his script so the next deployment would get it right. The home page opened, the price list displayed in the browser's PDF viewer, and the gallery came back. He also added a three-line check to the end of his script that prints the content type of the home page, one stylesheet and one script. "If those three are right," Ethan told him, "the rest almost always are."
📌 If you keep one line from this page
S3 serves the Content-Type and Content-Disposition saved with each object, so a file that downloads instead of opening needs its metadata fixed: copy it onto itself with --content-type and --metadata-directive REPLACE, set ContentType in every SDK upload, and invalidate CloudFront afterward.
boto3 guesses nothing · REPLACE drops other metadata · text/javascript for modules, text/css for styles.
Revision note. Written October 21, 2026. We will update this page when S3, the AWS CLI or browser behavior around content types changes.