Declares Content-Length: 1000 and delivers 500 bytes, then closes. An incomplete message: HTTP/1.1 sees a short read, HTTP/2 a stream reset.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/truncate'


A chunked body trickled out over seconds. Tests read timeouts and streaming consumers.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/drip'


Headers withheld for N seconds. Tests connect-vs-read timeout handling.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/delay'


Fails a settable percentage of the time. Tests retry logic.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/flaky'


Not a defect: reflects the request headers as this Worker sees them, which is how you discover what the edge changed on the way in.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/headers'


Not a defect: reflects the request body, capped at 16 KB.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/echo'


Any status code, returned verbatim, with the headers that code requires (Allow on 405, WWW-Authenticate on 401, Retry-After on 503). Codes with no body per RFC 9110 get none. Unregistered codes in each class are served too, which is where clients that switch on a hard-coded set fall over.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/status/418'


Cut off mid-stream; Content-Length matches what was sent.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/truncated'


Trailing comma. Valid JSON5, invalid JSON.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/trailing-comma'


Single-quoted strings. Python repr, not JSON.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/single-quotes'


NaN and Infinity literals. Python json.dumps emits these by default.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/nan'


UTF-8 byte order mark before the JSON. Some parsers choke.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/bom'


An HTML error page served with Content-Type: application/json and a 200.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/html'


Perfectly valid JSON served as text/html.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/mislabeled'


Zero-byte body with Content-Type: application/json and a 200.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/empty'


Unterminated string with an escaped quote inside.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/unterminated'


An integer above 2^53 and a number above double range. Precision loss or Infinity in most JS parsers.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/bigint'


Duplicate keys. Last-one-wins in most parsers, but not all.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/duplicate-keys'


curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/comments'


curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/leading-garbage'


Two JSON documents back to back with no separator (not NDJSON, no newline).

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/concatenated'


5,000 levels of nested arrays. Recursive parsers may blow the stack.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/deep'


Valid JSON, encoded as UTF-16LE with a BOM, labeled charset=utf-8.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/badjson/utf16'


A correct stream, for comparison: retry, id, event and data fields, blank-line delimited, then a clean close. ?events= and ?interval= (ms).

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/ok'


Headers and one event arrive, then nothing for ?seconds= (default 10, max 20), then a clean close. A client with a connect timeout but no read timeout waits here.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/stall'


Ends mid-event with a clean close: a complete event, then "data: {\"partial\":tr" and EOF with no blank line. The spec says discard it; many parsers emit it or leak it into the next connection.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/cut'


The connection is reset mid-event (HTTP/1.1: closed with bytes outstanding; HTTP/2: RST_STREAM). Your client should report an error, not a clean end. Carries a Content-Length so the runtime can reset for real.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/drop'


Every line ends in CRLF. The spec allows CR, LF or CRLF; parsers that split on "\n" leave a trailing "\r" in every value.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/crlf'


Every line ends in a bare CR. Spec-legal. Almost nobody handles it.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/cr'


Field values with no space after the colon, two spaces, and nothing at all. The spec strips exactly one leading space: "data:foo" is "foo", "data:  foo" is " foo".

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/no-space'


Multiple data: lines per event (joined with "\n"), a colon inside a value, an empty data: line in the middle. Parsers that keep only the last line, or split on ":", fail here.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/multiline'


A leading BOM, ": keepalive" comment lines, unknown fields ("foo: bar") and a field line with no colon. All four must be ignored; you should see exactly three events.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/comments'


Multi-byte UTF-8 characters split across chunk boundaries (a 4-byte emoji as 2+2, a 3-byte euro sign as 1+2). A client that decodes each chunk separately sees U+FFFD.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/split-utf8'


A valid stream served as text/plain. A browser EventSource must fail; does your client notice?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/wrong-type'


An event whose name is "error". EventSource routes it to onerror beside real transport failures; does your client tell them apart?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/error-event'


One event with a ?bytes= (default 64 KiB, max 1 MiB) data line. Line buffers with a fixed cap truncate or crash.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/big'


Sends events 1–3 and closes. Reconnect with Last-Event-ID: 3 and it sends 4–6; with 6 it answers 204 (stop). Its retry is 1000 ms so the three requests complete quickly. Does your client send Last-Event-ID on reconnect, and stop on a 204?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/sse/resume'


The control, fully correct: Accept-Ranges, strong ETag, Last-Modified; single, suffix, open-ended and multiple ranges (multipart/byteranges), 416 with "bytes */length" when unsatisfiable, If-Range honored (strong validators only), and the conditional headers evaluated as RFC 9110 says. HEAD ignores Range, as the spec requires. No Range gets a 200.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/ok'


No range support at all: no Accept-Ranges header, and every request gets a 200 with the full body. Legal — range support is optional — and the #1 real-world case: a resuming client must notice the 200 and start over, not append.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/ignore'


Advertises Accept-Ranges: bytes on every response, then ignores every Range header and sends 200 with the full body. Aimed at segmented downloaders (aria2 and friends) that split into N connections because of the advertisement — and then receive N full bodies.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/advertise-only'


Treats the range end as exclusive: bytes=a-b gets bytes a..b-1, one short, while Content-Range still claims a-b. Content-Length matches the short body, so the two headers disagree — the classic fencepost, one missing byte per segment. The lie is applied to the first range; extra ranges are ignored.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/off-by-one'


Serves bytes a+1..b+1 while Content-Range claims a-b. The envelope looks right; every byte is wrong — detectable only because the body is self-describing (the offsets inside the file will not match where you put them). At the end of the document the shifted window is clamped, so the final segment also runs one byte short. First range only.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/shifted'


The naive suffix-range bug: bytes=-n is served as the FIRST n bytes of the document while Content-Range claims the last n. A client resuming "the tail" appends the head — silent corruption on exactly the request curl sends for a suffix. The lie applies when the first range is a suffix; any other request is served correctly, multipart included.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/suffix-as-prefix'


Acknowledges your range with a 206 — then serves the whole document from byte zero, with an honest Content-Range: bytes 0-{length-1}/{length} that simply disagrees with what you asked. A client that appends without checking Content-Range against its request builds a file with a duplicated prefix.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/from-zero'


Correct bytes, but Content-Range lies about the total: bytes a-b/{2×length}. Asking for bytes past the real end gets 416 with the same inflated total, so a download loop that trusts it never finishes. First range only.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/wrong-total'


A single-range 206 with the right bytes and no Content-Range header (a violation of RFC 9110 §15.3.7). What offset does your client think this is? First range only.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/no-content-range'


A request with no Range header still gets a 206 (Content-Range: bytes 0-{length-1}/{length}, full body). With a valid Range it behaves correctly; an ignored Range (malformed, or over the caps) is treated as absent, so it also gets the full-body 206. Some CDNs and proxies really do this.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/always-206'


Honors the range — right bytes, right Content-Range header — but the status is 200. A contradiction: which does your client believe, the status or the header? First range only.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/200-content-range'


Every Range request gets 416 with Content-Range: bytes */{length}; without Range, a 200. Tests give-up-and-restart logic.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/always-416'


Correct 206, but Content-Range says bytes a-b/* — total unknown, which is legal. Preallocation and progress logic that requires the total breaks. First range only.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/unknown-total'


The resource changes on every request (a generation stamp appears in the ETag and in every line of the body) and If-Range is ignored: a stale validator still gets a 206 from the new generation, where a correct server would send the full 200. Resume across it and your file mixes generations — run grep -oE "g[0-9a-f]{16}" file | sort -u on it: more than one value is the corruption. Nondeterministic by design.

curl --http1.1 --raw -sS -D - -H 'range: bytes=0-99' --output - 'https://badhttp.dev/range/if-range-ignored'


The control, fully correct: strong ETag, Last-Modified, cache-control: no-cache. If-Match (strong compare; * passes), If-Unmodified-Since, If-None-Match (weak compare, lists and * supported; match is 304), If-Modified-Since (ignored when If-None-Match is present, and ignored unless it is a valid HTTP-date). The 304 carries the ETag.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/ok'


The only validator is weak: W/"…". If-None-Match uses weak comparison, so revalidation works (304). If-Match requires strong comparison and a weak validator never strong-matches, so every If-Match gets 412 — except If-Match: *, which passes. Catches clients that treat W/ as part of the value.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/weak'


A different strong ETag on every response, and no Last-Modified (a changing resource with a frozen date would be a second lie). If-None-Match never matches, so a cache revalidates forever and re-downloads every time: thrash. Nondeterministic by design.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/changing'


Sends a perfectly good ETag and Last-Modified, then ignores every conditional header: always 200, full body. (Violates a MUST. That is the point.) Your cache keeps asking; it keeps not listening.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/ignore'


Every GET is answered 304 — even the first, with no conditional headers at all. A cold cache is told "you already have it" about a body it has never seen. Broken proxies really do this.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/always-304'


Revalidation "succeeds" — If-None-Match matches, 304 — but the 304 carries a different ETag than the one you sent. A cache that adopts it misses on its next revalidation (200, real validator restored) and then matches again: a permanent 304/200/304 thrash.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/mismatch'


If-None-Match matches and the 304 comes back bare: no ETag, no Last-Modified. A violation — RFC 9110 §15.4.5 says the 304 MUST carry the ETag its 200 would have — and hostile to caches that need the validator to know which stored response was confirmed.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/no-validator-304'


The ETag header is a bare token with no quotes (spec-invalid, common in the wild). The server matches If-None-Match sloppily — quoted, bare, weak-prefixed, anything goes — and If-Modified-Since works normally. What does your client send back, and does its parser cope?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/unquoted'


No ETag; Last-Modified is ISO 8601, not an HTTP-date (invalid). Revalidation is by exact string comparison of If-Modified-Since against that value — what a naive server does. Only a client that echoes the header back verbatim ever gets its 304; one that parses and reformats, or discards the unparseable date, refetches forever.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/bad-date'


No ETag; Last-Modified is one year from today (a valid HTTP-date that is always in the future, moving at midnight UTC). The date comparison itself is honest, so a client that echoes today's header back verbatim still gets 304 — until the date rolls — while one that sends its own clock always gets 200. Which is yours? Nondeterministic across days by design.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/etag/future'


The control, fully correct and minimal: badhttp_ok=1; Path=/cookies; Max-Age=3600. Store it, return it to /cookies/* for an hour.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/ok'

The cookie is stored and comes back on every /cookies/* request for an hour.


The readback. Sets nothing; returns the Cookie header you sent (raw, plus base64 of its UTF-8 re-encoding) and the parsed pairs in order, duplicates preserved. Three notes: an upstream hop joins multiple Cookie header lines with "; " before the Worker sees them; the runtime replaces bytes that are not valid UTF-8 with U+FFFD — so EF BF BD in the base64 means your client sent raw non-UTF-8 bytes; and a Cookie header over 8,199 bytes is silently dropped upstream of the Worker (observed live: 8,199 arrives intact, 8,200 never arrives, the request otherwise succeeds).

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/echo'

Whatever your client sent, as delivered to the Worker.


Two cookies folded into ONE Set-Cookie header, comma-separated: "badhttp_folded_a=1, badhttp_folded_b=2". bis §3 forbids folding Set-Cookie (RFC 6265 had it at SHOULD NOT); the parse algorithm yields ONE cookie whose value is "1, badhttp_folded_b=2". A client that splits on commas invents a second cookie.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/folded'

One stored cookie, badhttp_folded_a, with the comma and the fake second cookie inside its value.


?count= separate Set-Cookie headers (1-20, default 10), badhttp_many_01 onward, zero-padded. Tests per-response cookie handling and ordering.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/many'

All stored and all sent back. §5.4's sort (longer path, then earlier creation) is a SHOULD: expect creation order, but an unsorted jar is still conformant.


The same name twice with different paths: badhttp_dup=deep; Path=/cookies/echo and badhttp_dup=shallow; Path=/cookies. Two distinct cookies. A jar keyed on name alone silently loses one.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/duplicate'

At /cookies/echo the Cookie header carries both — longer path first per §5.4's SHOULD-order: badhttp_dup=deep; badhttp_dup=shallow.


A 302 to /cookies/echo that carries Set-Cookie: badhttp_redirect=1 ON the redirect itself. A historic bug class: clients that drop Set-Cookie on 3xx responses. One shot: curl -sL -c jar -b jar.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/on-redirect'

The cookie from the 302 is stored and presented on the follow-up to /cookies/echo.


The cleanup: one expiring Set-Cookie for every cookie this family can plant, each with the exact Path (and Domain, and prefix-required attributes) it was set with — RFC 6265 §5.3 removes a cookie only on a name+domain+path match, so a deletion that is casual about attributes deletes nothing. Uses both idioms: Expires in 1970 and Max-Age=0.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/delete'

A jar that visited every flavor is empty again (a correct jar simply rejects the deletions for cookies it refused to store in the first place).


badhttp_conflict=alive with BOTH Expires in 1970 AND Max-Age=3600. §5.3 step 3 consults Max-Age before Expires (prose in §4.1.2.2: Max-Age has precedence). A client honoring Expires deletes a cookie that should live an hour.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/conflicting-expiry'

The cookie lives: Max-Age beats Expires.


Expires in ISO 8601 (2027-08-23T12:00:00Z), which the cookie-date algorithm (§5.1.1) cannot parse — "-" is a delimiter and no month token survives. The attribute is ignored and the cookie becomes a session cookie. A homegrown jar that feeds Expires to a general date parser mints a 2027 expiry instead; /cookies/delete clears it either way.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/bad-expires'

A session cookie: stored, sent back, gone when the jar session ends.


Expires in the year 9999 (Fri, 01 Jan 9999 00:00:00 GMT). bis §5.5 says user agents SHOULD cap cookie lifetime (400 days recommended); CLI jars mostly predate the cap. /cookies/delete removes it.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/far-future'

Stored either way; a client with the bis cap clamps the expiry, one without keeps the 9999 date.


Domain=example.com on a cookie set by this host. The Domain does not domain-match the request host, so the whole cookie MUST be ignored (§5.3 step 6). Jar-observable: it must simply never appear. Max-Age is 300 s so a jar that wrongly keeps it is only polluted briefly.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/wrong-domain'

Nothing is stored. If your jar has badhttp_wrong_domain, that is the bug.


Domain=dev — a public suffix. A cookie scoped to a whole TLD is a supercookie; a jar configured with a public-suffix list ignores it (§5.3 step 5 — conditional on that configuration; plain RFC 6265 without a PSL would accept it, since badhttp.dev domain-matches dev). On this host the single-label Domain also trips the older no-embedded-dot heuristic, so PSL-free jars reject it too; only a jar with neither guard stores it — and would then send it back here, so /cookies/echo can catch it. Max-Age 300 s bounds the damage.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/public-suffix'

Nothing is stored; badhttp_supercookie in any Cookie header is the bug.


The accepted-Domain pair: badhttp_domain_dot with Domain=.<this host> (leading dot) and badhttp_domain with Domain=<this host>. §5.2.3 strips the leading %x2E, so both become identical domain cookies (host-only flag off) — a classic divergence between jar generations and jar file formats. Meaningful on badhttp.dev itself.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/domain'

Both stored and both sent back; the jar file shows whether your client recorded them as domain cookies (curl writes a leading .badhttp.dev) and that the dot made no difference.


Path=/cookie — one letter short of /cookies. Path-matching (§5.1.4) requires the prefix to end at a "/" boundary, so this cookie must NEVER be sent to /cookies/*. A naive prefix-matcher sends it anyway.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/path-prefix'

Stored, but never sent back here. badhttp_path_prefix at /cookies/echo is the bug.


Four prefixed cookies (bis §4.1.3, §5.4 — the prefixes do not exist in RFC 6265): __Host-badhttp_good (valid: Path=/, Secure, no Domain), __Host-badhttp_bad (invalid: Path is not /), __Secure-badhttp_good (valid: Secure), __Secure-badhttp_bad (invalid: no Secure). A bis client stores exactly the two _good ones; an RFC-6265-only jar conformantly stores all four. Meaningful over HTTPS only.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/name-prefixes'

A current client keeps the two _good cookies and rejects the two _bad; a pre-bis jar keeps all four.


badhttp_quoted="hello world" (a DQUOTE-wrapped value with a space) and badhttp_semi="semi;colon" (a semicolon inside the quotes — but the parser splits on ";" before it ever sees quotes, so the stored value is "semi with an unclosed quote). What does your client send back — quotes kept, stripped, re-added?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/quoted'

badhttp_quoted comes back with the quotes and the space; badhttp_semi comes back as "semi (quote open, nothing else).


A value of raw UTF-8: badhttp_utf8=☃ (the bytes e2 98 83 on the wire; the runtime UTF-8-encodes header strings, which is itself a platform quirk worth knowing). Outside the cookie-octet grammar; real servers do it anyway. Jars differ: store raw, percent-encode, or drop. The echo base64 field shows exactly what came back.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/utf8'

Whatever your jar did with the bytes, the echo shows it.


Two nameless shapes at different paths so both can coexist: a Set-Cookie with no "=" at all (badhttp-just-a-value, default path /cookies) and one that starts with "=" (=badhttp_empty_name; Path=/cookies/echo). RFC 6265 §5.2 ignores both — step 2 (no "=") and step 5 (empty name); the bis parse stores each as a value with an empty name. Generations of jars really do differ here.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/nameless'

Spec-dependent: an RFC 6265 jar stores neither; a bis-style jar stores both as nameless values and echoes them bare.


One cookie whose name plus value sum to exactly ?bytes= bytes (64-8192, default 4096), padded with x. bis §5.6 step 5 says a client MUST ignore the cookie when name+value exceed 4096 bytes (RFC 6265 §6.1 has only a SHOULD-support floor, measured including attributes). So 4096 survives a conformant jar and 4097 must not. The echo round trip tells you your client's cap.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/cookies/huge'

At 4096 the cookie survives a bis-conformant jar; at 4097 it must be ignored — and pre-bis jars have their own caps.


The control, RFC 7617 done right: 401 with WWW-Authenticate: Basic realm="badhttp", charset="UTF-8" until you send agent:correct. Wrong credentials get a fresh challenge; a value that does not decode (bad base64, no colon) gets a 401 whose body names the exact defect.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/basic'


The control, RFC 6750 done right: a bare Bearer challenge (no error param) until credentials arrive. badhttp-token-ok is a 200; an unknown token is 401 error="invalid_token"; a value that is not token68-shaped is 400 error="invalid_request"; badhttp-token-limited is 403 error="insufficient_scope", scope="badhttp:full" — and that 403 carries the challenge, unlike /auth/forbidden.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/bearer'


The control, RFC 7616 with algorithm=MD5, qop="auth": full validation (username, realm, uri against the request-target, nonce, response hash, cnonce and nc required), stale=true when the nonce ages out (5–10 min), Authentication-Info with rspauth on success. The nonce is a deterministic time bucket, which trades RFC-advised uniqueness for statelessness — nothing here is protected, so replay is a non-issue. MD5 is for interop testing, not an endorsement.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/digest'


The same correct Digest with algorithm=SHA-256 (RFC 7616's preferred). Some clients only speak MD5 and fail here — how loudly is the test.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/digest-sha256'


A 401 with no WWW-Authenticate header at all — violates a MUST (RFC 9110 §15.5.2), rampant in real APIs. There is no challenge to answer, so only a client that sends Basic agent:correct preemptively, unprompted, ever gets its 200: this flavor is the preemptive-auth witness.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/none'


WWW-Authenticate: Basic — no realm, which RFC 7617 requires. Sends agent:correct anyway? It works. What does your client make of a challenge with no parameters at all?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/bare-scheme'


A challenge in a scheme nobody speaks: X-Badhttp-Frobnicate realm="badhttp". Every request is 401. A good client fails cleanly and does not loop; it certainly does not crash.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/unknown-scheme'


One header, two challenges, and the first ends in a token68 (X-Badhttp-Opaque dG9rZW42OA==, Basic realm="badhttp") — legal per RFC 9110's ABNF and harder on comma-naive parsers than /auth/multi, because the first challenge has no name=value shape at all. Valid Basic credentials work.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/token68'


One header, two challenges: Digest (realm="badhttp", qop="auth", algorithm=MD5), then Basic realm="badhttp" — the comma-separated challenge list that breaks parsers which split on commas, since parameters and challenges share the delimiter. Either valid Basic or valid Digest works; the body says which the server matched.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/multi'


The challenge arrives as bASIc rEALM="badhttp". Scheme names and parameter names are case-insensitive (RFC 9110 §11.1); agent:correct works — if your client recognized the challenge at all.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/case'


The realm is "badhttp says \"hello\", agent" — escaped quotes and a comma inside the quoted string (and it still names badhttp, the one place a browser might display it). A parser that splits on commas before honoring quotes sees two garbage challenges. agent:correct works.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/quoted'


Basic realm="badhttp-utf8", charset="UTF-8", credentials agent / sésame. The é forces an encoding choice, and charset is purely advisory (RFC 7617 §2.1), so both the UTF-8 and the Latin-1 encoding are accepted and the 200 reports which one your client sent (encoding: "utf-8" or "latin1"). A witness instrument, not a gate.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/utf8'


A perfect Basic challenge that rejects everything — agent:correct included (an x-badhttp-warning header says so). The retry-loop trap: how many times does your client try before giving up?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/always-401'


The opposite trap: any nonempty Authorization header is a 200 with authenticated:true and checked:false — the middleware bug that checks presence, not validity. The body names the scheme only when it is one the server knows (Basic, Bearer, Digest), never anything else you sent. If you saw authenticated:true here without configuring the documented test credentials, your client just leaked ambient credentials to a server that accepts anything — treat them as exposed.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/accept-any'


agent:correct authenticates — and gets 403, with no WWW-Authenticate on it: authenticated is not authorized, and a client SHOULD NOT auto-retry a 403 (RFC 9110). Compare /auth/bearer's insufficient_scope 403, which does carry a challenge.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/forbidden'


The Digest stale dance, deterministic: the first challenge's nonce is generation 1; a VALID response over it gets 401 with stale=true and a generation-2 nonce (stale=true promises the credentials were right — a client that honors it retries without prompting); a valid response over generation 2 is the 200. A wrong password gets a plain 401, never stale.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/stale'


An origin server demanding proxy authentication: 407 with Proxy-Authenticate: Basic realm="badhttp-proxy" from a host that is not your proxy. Proxy-Authorization with agent:correct works. A client that answered this automatically just revealed it would leak its proxy credentials to any origin that asks.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/proxy'


A 302 to /auth/basic. The question is what your client does with credentials across the hop: does the Authorization it was about to send (or was sent here with) follow to the redirect target? Same host, so this is the benign half of the cross-origin credential-leak class — the observable is whether auth survives a redirect at all.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/auth/redirect'


The control: gzip if the set the edge reports for you lists gzip, identity otherwise (that set reads no header, "*" and an empty value as nothing); Vary: Accept-Encoding; a correct Content-Length as sent; and the plaintext's length and SHA-256 in x-badhttp-plain-bytes / x-badhttp-plain-sha256 so you can prove what you decoded. q-values are already dropped from that set, so gzip;q=0 still gets gzip and identity;q=0 cannot produce a 406 from here; x-badhttp-negotiated-from shows the set.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/ok'

Nothing to transcode: a client that did not list gzip is sent identity by this Worker itself — unless it refused gzip with q=0, which the edge reports as listing it, and then it gets gzip.

The response carries the SHA-256 and byte length of the PLAINTEXT it should decode to (x-badhttp-plain-sha256 / x-badhttp-plain-bytes). Pin those, not the compressed bytes: the wire size belongs to this runtime's zlib and is not a stable fact about the flavor.


Brotli (RFC 7932), pre-computed for the default 4096-byte document because this runtime has no brotli encoder; ?length= is accepted only at 4096.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/br'

Delivered only to a client whose Accept-Encoding lists br; decoded to identity for everyone else, Content-Encoding removed and Content-Length dropped.


Zstandard (RFC 8878), the newest coding browsers advertise and one several SDKs still cannot decode. Pre-computed for the default 4096-byte document; ?length= is accepted only at 4096.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/zstd'

Delivered only to a client whose Accept-Encoding lists zstd (the edge honours that even though it drops zstd from the set it reports to this Worker); decoded to identity for everyone else.


Content-Encoding: deflate done right: a zlib stream (RFC 1950 around RFC 1951), which is what the coding name means. No client probed on 2026-09-01 received it through this host — Cloudflare's edge, the CDN in front of this Worker, decoded it for every one of them, even one that asked for it (GET /compress → edge_transcoding has the per-flavor list) — and the flavor exists so that observation stays reproducible.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/deflate'

Decoded to identity for every client probed on 2026-09-01, including one that sent Accept-Encoding: deflate — the only coding among those probed that no client received.


Content-Encoding: gzip on a body that is not compressed — plain text, the case most often reported in the wild (a proxy or framework that sets the header without compressing). A decoder that trusts the header fails on the first byte; some clients fall back to the raw body, some do not.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/not-compressed'

Cannot decode it, so it removes the header and delivers the text: a client without gzip sees a correct response.


The mirror image: the body IS gzip (starts 1f 8b) but there is no Content-Encoding header at all — text/plain says it is text. A client that trusts the headers hands you binary; a few sniff the magic bytes.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/undeclared'

Nothing to transcode: everyone gets the gzip bytes labelled as text.


A valid gzip stream cut at about 60% (x-badhttp-wire-bytes-of says exactly) — inside the DEFLATE data, before the CRC-32 and length trailer. Content-Length matches the bytes actually sent, so the transport looks complete; only the decoder can tell. Does your client report the error, or return the partial plaintext as if it were everything?

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/truncated'

Inflates what it can and ends the body cleanly: a 200 with the partial plaintext (1,860 of 4,096 bytes at the default length; a correct prefix of 628,228 bytes at 1 MiB) and no error anywhere. For a document of a few bytes the partial text may be all of it.


A gzip stream with up to four bytes flipped in the middle of the DEFLATE data (x-badhttp-flipped-bytes says how many and where; fewer only for streams under 25 bytes). A decoder either rejects the stream outright, or inflates it — often to the full length, correct up to the damage and garbage after — and then fails the CRC; a decoder that does not check the CRC (see bad-crc) returns that garbage with no error at all. Either way it may have handed you some of the garbage first.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/corrupt'

At the default length, a 200 with an EMPTY body: the decode fails, nothing is sent, no error anywhere. At 1 MiB it streamed the correct plaintext up to the damage and garbage after it, 60 bytes short, still no error — the CRC-skipping outcome.


A gzip stream whose DEFLATE data is intact but whose CRC-32 trailer is wrong (the ISIZE is right). Every byte of plaintext is recoverable; the question is whether your client checks the checksum, and what it does with the data it already delivered when the check fails at the very end.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/bad-crc'

Does not check the trailer: the full plaintext, no error.


A complete, valid gzip member followed by 49 bytes of junk, all counted in Content-Length. RFC 1952 says a stream is a series of members; the junk is not one. Some decoders ignore it, some fail after having produced the whole plaintext, some warn.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/trailing-garbage'

The full plaintext; the junk is dropped without a word.


Two gzip members back to back, each holding half the document — valid per RFC 1952 §2.2, and what some servers and concatenating CDNs really emit. Some HTTP clients decode only the first member and return half the document with no error (httpx and Ruby's Net::HTTP among them, 2026-09-01).

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/multi-member'

The first member only — half the document (2,048 of 4,096 bytes at the default length), no error.


Content-Encoding: gzip, gzip — the document gzipped twice, with both codings declared in order, exactly as RFC 9110 §8.4 allows. Clients that apply only the first coding hand you a gzip stream and call it text; clients that compare the header to "gzip" exactly do not decode at all.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/double'

Removes exactly one layer and relabels the rest Content-Encoding: gzip — self-consistent (the header still matches the body), though RFC 9110 §7.7 forbids the transformation under no-transform, and the one case where the edge itself hands a client a coding it recognizes that the client never listed.


The document gzipped twice but Content-Encoding says gzip once. Decoding "succeeds" and yields 1f 8b…: a gzip stream where your text should be. The bug proxies that compress already-compressed responses produce.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/double-hidden'

Removes one layer and the header: gzip bytes labelled as text.


Content-Encoding: deflate with a raw RFC 1951 stream — no zlib header, no Adler-32 — the historic mistake that made "deflate" untrustworthy. Some clients detect the missing wrapper and recover; some fail on byte one; some do not decode deflate at all.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/deflate-raw'

Cannot inflate it: the raw bytes go through with the header removed. Only a client whose reported set lists gzip gets them with the header intact; listing deflate does not help — the header is removed for that client too.


Content-Encoding: badhttp, a coding that does not exist, on a plain-text body. There is no rule for a client here: pass the bytes through, or refuse. Which does yours do, and does it tell you?

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/unknown-coding'

Unrecognized, so untouched: everyone gets it as sent.


Content-Encoding: GZIP. Coding names are case-insensitive (RFC 9110 §8.4.1); the body is ordinary gzip. Clients that compare the header case-sensitively leave it compressed.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/uppercase'

Recognized: decoded to identity for a client without gzip.


Content-Encoding: x-gzip, the legacy alias that RFC 9110 §8.4.1.3 says a recipient SHOULD treat as gzip. The body is ordinary gzip.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/x-gzip'

Not recognized as gzip: passed to everyone as sent, header and all, whatever they advertised.


Content-Encoding: gzip on a zero-byte body (Content-Length: 0) — what a proxy that strips a body but not its headers produces; with ?code=502 it is the stripped error page. An empty string is not a gzip stream; some decoders say so, some return nothing quietly. ?length= is accepted only at 0.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/empty'


A correct gzip body whose Content-Length is the plaintext's length, not the wire's — the header a proxy leaves behind when it compresses without recounting. The declared bytes never arrive: the connection closes early (HTTP/1.1) or the stream is reset (HTTP/2), after the decoder has already seen a complete gzip stream. Which error do you get, if any? ?length= is at least 128 here, so the gzip member is really shorter than the length it claims.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/wrong-length'

Decodes and re-chunks: the lie disappears and a client without gzip gets a complete, correct response.


The .gz download trap: a file that already IS gzip, served as a download (Content-Type: application/gzip, Content-Disposition: attachment; filename="badhttp-compress.txt.gz") — and, wrongly, with Content-Encoding: gzip as well, the way misconfigured static servers and object stores label .tar.gz files. A client that applies transport decoding saves the plaintext under a .gz name: silent corruption of the download. x-badhttp-wire-sha256 is the hash of the bytes that should land in the file; x-badhttp-plain-sha256 the hash of what lands if the client decoded.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/gzip-file'

Commits the corruption itself: a client without gzip receives the plaintext, still labelled application/gzip with the .gz filename, Content-Encoding and Content-Length removed (observed 2026-09-02).


A decompression bomb, declared: one gzip member that inflates to ?size= MiB (default 8, max 32) from about 3 KB per MiB on the wire (x-badhttp-wire-bytes says exactly) — the same 64-byte line repeated. Only the requester's own memory is at risk: a client that decodes without a size limit allocates the whole thing. x-badhttp-plain-bytes states the inflated size, x-badhttp-plain-line carries the line without its newline (63 characters; the plaintext is that line plus a newline, repeated), and x-badhttp-plain-sha256 is the hash of the whole.

curl --http1.1 --raw -sS -D - -H 'accept-encoding: gzip' --output - 'https://badhttp.dev/compress/bomb'

Not served: a client whose reported set lacks gzip gets a 406 asking for Accept-Encoding: gzip. That set reads as empty for a client that sent no Accept-Encoding at all, or only "*" — requests RFC 9110 §12.5.3 says accept gzip — so the 406 is not negotiation but a refusal: the edge would otherwise inflate the member itself and ship the whole ?size= MiB as text (observed before the 406 was added), testing no decoder.


Returns 402 with x402 requirements in both generations at once: v2 in the PAYMENT-REQUIRED header, v1 in the JSON body (Base Sepolia unless you ask for mainnet with /402/pay/base or ?network=base). Send a valid PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) and the payment is verified and settled through a facilitator; you get a 200 with the transaction hash and a receipt in PAYMENT-RESPONSE (v2) or X-PAYMENT-RESPONSE (v1).

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/pay'


Always 402, with perfectly valid requirements. Any PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) you send is ignored. Nothing is verified or settled. Does your client stop after one retry, or loop and re-sign forever?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/never'


Valid 402; then every payment is rejected with a 402 carrying an error (default insufficient_funds; pick another with ?reason=). Nothing is settled. A client should surface the reason and stop, not re-sign.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/reject'


Valid 402; after you pay, the server sits on the request for ?seconds= (default 8, max 10) and then answers 504 with no receipt. In the real world you would not know whether you were charged. Here, nothing was.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/slow'


Valid 402; after you pay, a 500 with no PAYMENT-RESPONSE. A real server might have settled before it crashed. This one never does. Does your client treat this as "paid" or "unpaid"?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/crash'


Valid 402; after you pay, a 200 whose receipt headers — PAYMENT-RESPONSE (v2) and X-PAYMENT-RESPONSE (v1) — are both garbage, not valid base64 JSON. Nothing was settled. Does your client still hand you the body, or throw it away because the receipt is bad?

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/bad-receipt'


A valid 402 that asks for 1,000,000 USDC. A client with a spending limit should refuse to sign. If yours signs anyway, the response says so; the authorization is discarded and never settled. A less friendly server would have taken it.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/overpriced'


A valid-looking 402 whose only option is on eip155:424242, a chain nobody runs. A client should report "no supported network" and not sign. Nothing can be settled here by anyone.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/wrong-network'


GET /402/broken lists the flavors: not-base64, not-json, no-accepts, empty-accepts, no-extra, no-resource, version-99, decimal-amount, missing-header, v1-body. Each is a 402 that a sloppy client will mis-parse.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken'


curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/not-base64'


PAYMENT-REQUIRED is base64 of something that is not JSON.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/not-json'


A PaymentRequired object with no "accepts" field at all.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/no-accepts'


"accepts" is an empty list: nothing to pay with.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/empty-accepts'


The requirement has no "extra" (no EIP-712 domain name/version), so the authorization cannot be signed correctly.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/no-extra'


No "resource" object. The reference client treats it as optional and signs anyway; stricter clients refuse.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/no-resource'


curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/version-99'


"amount" is "0.01" instead of atomic units ("10000"). A client that does not validate will sign for 0.01 atomic units, i.e. nothing.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/decimal-amount'


A 402 with v2-shaped requirements only in the JSON body (x402Version 2) and no PAYMENT-REQUIRED header. Genuinely broken: v2 keeps requirements in the header, and the reference client reads a body only when it says x402Version 1, so it finds nothing here.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/missing-header'


A spec-valid x402 v1 response: requirements only in the JSON body (maxAmountRequired, plain-string network name), no PAYMENT-REQUIRED header. Not malformed — /402/pay serves this same v1 body underneath its v2 header — but a strictly header-only v2 client sees no requirements, which is the trap.

curl --http1.1 --raw -sS -D - --output - 'https://badhttp.dev/402/broken/v1-body'