Content-Type describes the representation a sender intends to provide. It does not inspect the body, convert its bytes, or guarantee that every recipient will process it identically. A reliable endpoint keeps its serializer, declared media type, and consumer expectations in agreement, including when an error takes a different path through the server.
Describe the representation, not the filename
For a normal response carrying content, application/json identifies JSON data and text/html identifies HTML. The type applies after content codings have been decoded. A gzipped JSON response therefore retains Content-Type: application/json and adds Content-Encoding: gzip. Content coding is a property of the representation, distinct from HTTP message transfer framing such as chunked transfer coding. Renaming a file or changing its extension does not perform either transformation.
Media types contain a type, subtype, and optional parameters. The type and subtype are case-insensitive; parameter meanings belong to the particular media type. Use an existing header parser instead of comparing the complete field to one literal string. Also inspect whether the field was actually present: some parsers supply a default that can hide missing metadata. Multiple conflicting Content-Type fields are an origin or proxy defect, not a useful list of alternatives.
For UTF-8 HTML, text/html; charset=utf-8 describes both format and character encoding. Do not append a charset indiscriminately to every format. RFC 8259 requires UTF-8 for JSON exchanged outside a closed ecosystem, and the application/json registration defines no charset parameter. A generic policy saying that every textual payload needs that parameter would misdescribe JSON’s contract.
When it breaks
A reverse proxy can replace an API response with an HTML login page while returning a successful status. A consumer that immediately parses JSON reports a syntax error, hiding the more useful fact that it received a different representation. Conversely, an error handler can send an HTML body while retaining the JSON header. Checking only the header then passes a response that cannot be parsed. Inspect status, metadata, and body together.
Missing metadata does not make all recipients reject the response. RFC 9110 permits treating an untyped body as opaque binary data or examining it to determine a type. Some user agents can override a supplied type through sniffing. Browser behavior also depends on the processing context, so a command-line JSON client is not a browser security test. Correct labels neither sanitize HTML nor establish that an uploaded file is safe to publish.
Check it quickly
Run this with Python 3.11 or newer. It creates an ephemeral loopback server, sends four real HTTP requests, and shuts the server down. There are no external requests or dependencies. The example consumer deliberately accepts only the application/json media type and then decodes UTF-8 and parses JSON; it is not a general media-type negotiation implementation.
import json
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
cases = {
"/ok": ("application/json", b'{"ok":true}'),
"/wrong-type": ("text/plain", b'{"ok":true}'),
"/bad-body": ("application/json", b"not JSON"),
"/missing-type": (None, b'{"ok":true}'),
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
media, body = cases[self.path]
self.send_response(200)
if media is not None:
self.send_header("Content-Type", media)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = Thread(target=server.serve_forever)
worker.start()
try:
outcomes = []
for route in cases:
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
try:
connection.request("GET", route)
response = connection.getresponse()
body = response.read()
assert response.status == 200
if response.headers.get("Content-Type") is None:
outcome = "missing type"
elif response.headers.get_content_type() != "application/json":
outcome = "wrong type"
else:
try:
value = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
outcome = "invalid body"
else:
assert value == {"ok": True}
outcome = "accepted"
outcomes.append(outcome)
print(route, outcome)
finally:
connection.close()
assert outcomes == ["accepted", "wrong type", "invalid body", "missing type"]
finally:
server.shutdown()
server.server_close()
worker.join()
The output is accepted, wrong type, invalid body, and missing type, in path order. The first, second, and fourth bodies are identical: their different outcomes isolate the declaration check. The third response has an acceptable declaration but fails JSON syntax, isolating body validation. These are the consumer’s explicit rules, not HTTP requirements that every client must reject these fixtures in this way.
For a real incident, capture a GET response from the same authenticated or anonymous path that fails, while keeping credentials out of diagnostic output. Record the final URL after redirects too: the intended resource and its login destination are different endpoints. Compare the origin response with the proxy response and check error routes as well as success routes. HEAD metadata alone cannot establish that a body parses. Fix the component that introduced the mismatch, then repeat the request and parsing check through the complete delivery path. Avoid solving a mislabeled error page by teaching the client to guess around it.
Sources
- RFC 9110: Content-Type, media types, missing fields, sniffing, and the adjacent content-coding definition, checked 2026-09-07.
- RFC 8259: JSON, UTF-8 interchange and the application/json registration, checked 2026-09-07.