badhttp v0.19.0
the server that misbehaves on purpose
Point an HTTP client, an SDK, or an agent at these URLs and find out what it does when the server is unkind. Every endpoint is stateless and documented. Everything is free except the /402 paywalls, which charge only if your client chooses to pay, and default to test USDC. There is no signup and nothing about you is stored.
curl -i "https://badhttp.dev/status/429?retry-after=3"
Machine-readable: /openapi.json · /llms.txt. Liveness: /health.
Status and timing
/status/{code}
Returns the status code you ask for, 200–599. Add ?retry-after=N to get a Retry-After header. Give a comma-separated list and it picks one at random. 204, 205 and 304 come back with no body, as the spec demands; 3xx come with a Location.
curl -i "https://badhttp.dev/status/429?retry-after=3"
curl -i "https://badhttp.dev/status/200,500,503"
/delay/{seconds}
Waits up to 10 seconds, then answers. Decimals allowed. Use it to test timeouts that are too short, and timeouts that are missing.
curl -m 1 "https://badhttp.dev/delay/3" # should time out
curl -m 5 "https://badhttp.dev/delay/3" # should succeed
/drip
Streams a chunked body one line at a time over ?duration= seconds (max 20) in ?chunks= pieces (max 200). Headers arrive immediately; the body dribbles. Clients with a connect timeout but no read timeout hang here.
curl -N "https://badhttp.dev/drip?duration=5&chunks=5"
/flaky/{percent}
Fails the given percentage of requests with a 500 (or ?fail=503). Add ?seed= and increment ?i= per attempt for a reproducible sequence, so a retry test can fail the same way every time.
curl -i "https://badhttp.dev/flaky/50?fail=503"
for i in 0 1 2 3; do curl -s "https://badhttp.dev/flaky/70?seed=ci&i=$i"; done
Bodies
/badjson/{flavor}
Serves JSON that is broken, mislabeled, or technically valid but hostile, always with a 200 unless you pass ?code=. GET /badjson lists the flavors.
curl -s "https://badhttp.dev/badjson/trailing-comma"
curl -si "https://badhttp.dev/badjson/html?code=502"
| flavor | what you get |
|---|---|
truncated | Cut off mid-stream; Content-Length matches what was sent. |
trailing-comma | Trailing comma. Valid JSON5, invalid JSON. |
single-quotes | Single-quoted strings. Python repr, not JSON. |
nan | NaN and Infinity literals. Python json.dumps emits these by default. |
bom | UTF-8 byte order mark before the JSON. Some parsers choke. |
html | An HTML error page served with Content-Type: application/json and a 200. |
mislabeled | Perfectly valid JSON served as text/html. |
empty | Zero-byte body with Content-Type: application/json and a 200. |
unterminated | Unterminated string with an escaped quote inside. |
bigint | An integer above 2^53 and a number above double range. Precision loss or Infinity in most JS parsers. |
duplicate-keys | Duplicate keys. Last-one-wins in most parsers, but not all. |
comments | JSONC comments. |
leading-garbage | A stray line before the JSON document. |
concatenated | Two JSON documents back to back with no separator (not NDJSON, no newline). |
deep | 5,000 levels of nested arrays. Recursive parsers may blow the stack. |
utf16 | Valid JSON, encoded as UTF-16LE with a BOM, labeled charset=utf-8. |
/truncate
Declares Content-Length: length, sends only send bytes, then closes the connection. A client that trusts Content-Length and does not check for a short read will happily return half a file. Over HTTP/1.1 the connection closes early; over HTTP/2 the stream is reset.
curl -sv "https://badhttp.dev/truncate?length=1000&send=500" -o /dev/null
Content codings
/compress/{flavor}
Content-Encoding that misbehaves: gzip declared on plain text, gzip with no header, streams that are cut, corrupt, checksum-wrong, concatenated, gzipped twice or followed by junk, raw DEFLATE sold as deflate, an alias and a case trap, a zero-byte gzip body, a compressed body under the plaintext's Content-Length, a .gz download that is transport-encoded as well, and a declared decompression bomb; br, zstd and deflate for the codings themselves; ok is the honest control. The document is the self-describing one from /range, and every response states the plaintext's length and SHA-256 in x-badhttp-plain-bytes / x-badhttp-plain-sha256, so you can prove what your client decoded. ?length= sets the size (default 4096, max 1 MiB); ?code=502 makes it an error body (a compressed or mislabelled error page is its own bug class); bomb takes ?size= in MiB (max 32). GET /compress lists the flavors, their parameters, and the Accept-Encoding the edge reports for you.
curl -s -H 'Accept-Encoding: gzip' "https://badhttp.dev/compress/truncated" | gzip -dc; echo "exit $?"
curl -s --compressed "https://badhttp.dev/compress/multi-member" | wc -c # 4096, or 2048 and no error?
curl -si -H 'Accept-Encoding: gzip' "https://badhttp.dev/compress/not-compressed?code=502" | head -c 700Send Accept-Encoding: gzip (br, zstd for those flavors) or you will not see these bytes: Cloudflare's edge, which fronts this Worker, removes one coding layer it recognizes (gzip, br, zstd) unless your Accept-Encoding lists it, transcoding the body to identity, cache-control: no-transform notwithstanding — and transcoding a broken stream is lossy. Codings it does not know (x-gzip, badhttp) and a second declared layer go through to everyone. The last column of the table says what each flavor became for a client without gzip (curl's default, Python's urllib), observed 2026-09-01. The edge also rewrites the Accept-Encoding header to gzip, br before the Worker runs and keeps only a normalized set (q-values dropped, q=0 included, zstd missing) in request.cf.clientAcceptEncoding, which is why ok cannot honour a refusal and never sends a 406 (bomb's 406 is the opposite case: this server refusing a client the edge would decompress for), and why the zlib-deflate flavor can never be received: the edge decoded it for every client probed, even one that asked for it.
Checked live with six real clients on 2026-09-02, each with its default Accept-Encoding and decoder (every observation below is served as data at /clients.jsonl, indexed at /clients, so you can check this paragraph rather than take it): curl 8.7 (--compressed), Node 26 fetch (undici 8.10), Python requests 2.34 (urllib3), httpx 0.28, Ruby 2.6 Net::HTTP, Go 1.27 net/http. The headline is truncated: curl (exit 0), Node, requests and httpx all return the partial plaintext with no error, Ruby returns an empty body with no error, and only Go reports it (unexpected EOF). gzip-file: all six apply transport decoding and would save the plaintext under the .gz name. multi-member: httpx and Ruby return the first member only, silently; curl stops after it with exit 56; Node, requests and Go decode both. double: Go and Ruby leave it compressed (the header is not exactly gzip); the other four decode both layers. double-hidden: all six hand you gzip bytes as text, exactly like undeclared. x-gzip: httpx and Go leave it compressed; curl, Node, requests and Ruby decode it. deflate-raw: curl, Node, requests and httpx detect the missing zlib wrapper and recover; Ruby raises; Go does not decode deflate at all and hands you the bytes. bad-crc: Node, requests, httpx and Ruby raise; curl and Go deliver the full text and then report the error. trailing-garbage: Node raises without delivering anything; Go delivers everything, then raises; curl delivers everything and exits 56; requests, httpx and Ruby ignore the junk. corrupt and not-compressed: all six report them — curl first writes what it has (the raw text for not-compressed, the decodable prefix for corrupt), Go returns the decodable prefix of corrupt. unknown-coding: all six hand you the body untouched (curl also exits 56). wrong-length: every client but Ruby reports the short read (Ruby returns the decoded text and says nothing). uppercase and ok: all six decode them. undeclared: all six hand you the gzip bytes as text. empty: none of the six objects. bomb: none of the six limits decompression — all allocate the 8 MiB. br and zstd: Node, requests and httpx decode both; curl (a zlib-only build), Ruby and Go do not advertise them and receive identity from the edge instead. deflate: none of the six receives it — the edge decodes it for all.
Event streams
/sse/{flavor}
Server-Sent Events streams that misbehave: wrong line endings, split multi-byte characters, events cut off mid-line, a reset connection, a stall, an event named error. GET /sse lists the flavors. Every stream starts with retry: 30000 so a browser waits 30 s before reconnecting (resume alone sends retry: 1000, so its reconnect round-trip is quick), and any request carrying a Last-Event-ID header is answered 204 No Content (the spec's "stop reconnecting" signal) except resume, which continues from it. No stream lasts longer than 20 s.
curl -N "https://badhttp.dev/sse/ok"
curl -N "https://badhttp.dev/sse/split-utf8" | xxd | tail -3
curl -sN --http1.1 "https://badhttp.dev/sse/drop"; echo "exit $?" # 18: closed with bytes outstandingEvery flavor was checked against a spec-conformant client (Node's built-in EventSource, 2026-08-23): it handles all fourteen as the spec says, so if your parser does not, the difference is in the parser. Chunk boundaries (split-utf8) and the reset (drop) were confirmed byte for byte on the live host.
| flavor | what you get |
|---|---|
ok | A correct stream, for comparison: retry, id, event and data fields, blank-line delimited, then a clean close. ?events= and ?interval= (ms). |
stall | 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. |
cut | 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. |
drop | 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. |
crlf | Every line ends in CRLF. The spec allows CR, LF or CRLF; parsers that split on "\n" leave a trailing "\r" in every value. |
cr | Every line ends in a bare CR. Spec-legal. Almost nobody handles it. |
no-space | 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". |
multiline | 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. |
comments | 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. |
split-utf8 | 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. |
wrong-type | A valid stream served as text/plain. A browser EventSource must fail; does your client notice? |
error-event | An event whose name is "error". EventSource routes it to onerror beside real transport failures; does your client tell them apart? |
big | One event with a ?bytes= (default 64 KiB, max 1 MiB) data line. Line buffers with a fixed cap truncate or crash. |
resume | 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? |
Ranges and caching
/range/{flavor}
Resumable downloads that misbehave: servers that ignore Range, serve the wrong bytes, lie in Content-Range, or hand out a range of a resource that changed underneath you. The document is deterministic and self-describing — 64-byte lines, each starting with its own offset — so when a flavor corrupts your download, the file itself shows you where. ?length= sets the size (default 1000, max 1 MiB). GET /range lists the flavors.
curl -s -r 128-255 "https://badhttp.dev/range/ok?length=512"
curl -s -r 128-255 "https://badhttp.dev/range/shifted" | head -2 # look at the offsets: they are wrong
curl -C 320 -o resumed.txt "https://badhttp.dev/range/ignore" # curl refuses: the server sent 200Checked live with curl 8.7 (2026-08-23): it resumes ok byte-identically; refuses ignore and advertise-only (exit 33 over HTTP/1.1, 56 over HTTP/2, file untouched); and completes shifted with exit 0 and a corrupted file one byte short — only the offsets inside the file give it away. An unseeded curl -C - sends no Range header at all, so seed a partial file first.
| flavor | what you get |
|---|---|
ok | 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. |
ignore | 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. |
advertise-only | 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. |
off-by-one | 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. |
shifted | 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. |
suffix-as-prefix | 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. |
from-zero | 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. |
wrong-total | 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. |
no-content-range | 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. |
always-206 | 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. |
200-content-range | 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. |
always-416 | Every Range request gets 416 with Content-Range: bytes */{length}; without Range, a 200. Tests give-up-and-restart logic. |
unknown-total | 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. |
if-range-ignored | 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. |
/etag/{flavor}
Conditional requests that misbehave: validators that change on every response, servers that ignore If-None-Match, a 304 for a body you never saw, an ETag without quotes, a Last-Modified from the future. Responses are cache-control: no-cache — a spec-following cache stores them and revalidates on every use, which is the game being tested. GET /etag lists the flavors.
curl -s --etag-save t.txt "https://badhttp.dev/etag/ok" && curl -si --etag-compare t.txt "https://badhttp.dev/etag/ok" # second is 304
curl -si -H 'If-None-Match: "e-badhttp-1"' "https://badhttp.dev/etag/mismatch" | grep -i '^etag'Checked live against a real RFC-9111 cache (Node 25's undici cache interceptor, 2026-08-23): it revalidates ok and serves the stored body on the 304; re-downloads changing every time; is not fooled by mismatch (it keeps its stored validator instead of adopting the 304's); and surfaces always-304's cold, body-less 304 straight to the caller.
| flavor | what you get |
|---|---|
ok | 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. |
weak | 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. |
changing | 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. |
ignore | 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. |
always-304 | 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. |
mismatch | 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. |
no-validator-304 | 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. |
unquoted | 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? |
bad-date | 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. |
future | 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. |
Cookies
/cookies/{flavor}
Set-Cookie headers that misbehave: two cookies folded into one header, the same name twice on different paths, a cookie set on a redirect, Max-Age contradicting Expires, an unparseable date, a Domain for another site, a whole-TLD supercookie, __Host-/__Secure- prefixes broken on purpose, quotes, raw UTF-8, no name at all, and a cookie sized to your client's limit. The server stays stateless — the state under test is your client's jar. /cookies/echo is the readback: it sets nothing and returns the Cookie header exactly as it reached the Worker, raw and parsed, order and duplicates preserved. GET /cookies lists the flavors and the politeness rules (scoped and short-lived except where the long date is the test; /cookies/delete cleans up).
curl -s -c jar -b jar "https://badhttp.dev/cookies/ok" && curl -s -c jar -b jar "https://badhttp.dev/cookies/echo"
curl -sL -c jar2 -b jar2 "https://badhttp.dev/cookies/on-redirect" # does the 302's cookie survive?
curl -s -b 'made=up; made=up-again' "https://badhttp.dev/cookies/echo"Checked live against three real jars (2026-08-23). curl 8.7 matches the table exactly: one cookie from the folded header, both duplicates (deep first), the 302's cookie captured, year 9999 kept at far-future (curl's 400-day clamp shipped later, in 8.12), exactly the two valid prefix cookies, and its jar file writes the domain flavor with a leading dot. Python's http.cookiejar stores all four prefix cookies (it has no prefix rules — RFC 6265-conformant), parses the no-equals nameless line as a cookie named badhttp-just-a-value with no value, and garbles ☃ internally while round-tripping the bytes faithfully. tough-cookie 6.0.2 rejects the supercookie by name ("public suffix"), rejects both nameless lines loudly, and drops the invalid prefix pair silently — check the jar, not the exception; its jar file records conflicting-expiry with the 1970 date even though Max-Age correctly wins. All three: Max-Age beats Expires, bad-expires becomes a session cookie, path-prefix is stored but never sent back here, and /cookies/delete leaves the jar empty. On the wire, verified through the production edge: the folded comma survives as one header, the raw ☃ bytes and an 8 KB Set-Cookie pass intact — and a request Cookie header over 8,199 bytes is silently dropped before the Worker sees it (the request otherwise succeeds).
| flavor | what you get |
|---|---|
ok | The control, fully correct and minimal: badhttp_ok=1; Path=/cookies; Max-Age=3600. Store it, return it to /cookies/* for an hour. |
echo | 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). |
folded | 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. |
many | ?count= separate Set-Cookie headers (1-20, default 10), badhttp_many_01 onward, zero-padded. Tests per-response cookie handling and ordering. |
duplicate | 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. |
on-redirect | 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. |
delete | 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. |
conflicting-expiry | 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. |
bad-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. |
far-future | 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. |
wrong-domain | 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. |
public-suffix | 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. |
domain | 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. |
path-prefix | 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. |
name-prefixes | 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. |
quoted | 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? |
utf8 | 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. |
nameless | 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. |
huge | 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. |
Authentication
/auth/{flavor}
HTTP authentication that misbehaves: a 401 with no challenge, an unknown scheme, two challenges jammed into one comma-joined header, a comma hiding inside a quoted realm, a server that rejects correct credentials forever, one that accepts anything, a 403 for a password that was right, the Digest stale=true dance, a 407 from a host that is not your proxy. The controls (basic, bearer, digest, digest-sha256) are fully RFC-correct. The test credentials are public and fake — user agent, password correct (utf8 flavor: sésame); Bearer badhttp-token-ok / badhttp-token-limited — and they are the only values any flavor ever accepts. Never send real credentials, and never point a credential store or ambient-auth client at badhttp: /auth/accept-any answers authenticated:true to any value, and that answer means nothing. Anything received is compared in memory and discarded — never stored, logged, or echoed. Any method works and is treated identically; the request body is never read. GET /auth lists the flavors and credentials. Digest's MD5 is interop testing, not an endorsement.
curl -u agent:correct "https://badhttp.dev/auth/basic"
curl --digest -u agent:correct "https://badhttp.dev/auth/digest"
curl -u agent:correct "https://badhttp.dev/auth/always-401" # rejected anyway; how often does your client retry?
curl -H 'Authorization: Bearer anything-at-all' "https://badhttp.dev/auth/accept-any"Checked live against eight real clients on 2026-09-18 — 144 observations, every one a row of /clients.jsonl (family auth) and summarized in the index under witness; a dated capture, not a live measurement, and every sentence that follows is computed from those rows rather than typed here. Each client was handed the documented fake credentials through its own mechanism for the flavor's scheme (mechanism_kind on every row says which), and every row records each request the client sent, its status, and whether it carried credentials. Whether credentials go out before any challenge is read from hops, and it follows the mechanism, not the client: on basic, curl, Go net/http, Node fetch (undici), Python requests, Python httpx, Python urllib3, Python aiohttp sent them on their first request; Python urllib.request sent nothing until the 401 arrived. On none — a 401 with no WWW-Authenticate at all — that decides everything: curl, Go net/http, Node fetch (undici), Python requests, Python httpx, Python urllib3, Python aiohttp authenticated and Python urllib.request could not, because there was no challenge to answer. urllib's HTTPBasicAuthHandler is the only challenge-driven Basic mechanism in this roster; every other client's Basic option sets the header before the first request (the stdlib also offers HTTPPasswordMgrWithPriorAuth for that form, not used here). digest: curl, Python urllib.request, Python requests, Python httpx, Python aiohttp completed the MD5 challenge (401 then 200: a probe without credentials, then the answer). Go net/http, Node fetch (undici), Python urllib3 have no Digest mechanism at all and were sent with no credentials, so their 401 there is a capability of the library, not a bug in it. net/http, fetch and urllib3 contain no challenge handling of any kind: a 401 is returned to the caller as an ordinary response, and nothing in those libraries reads WWW-Authenticate. digest-sha256: curl, Python urllib.request, Python requests, Python httpx, Python aiohttp completed the SHA-256 challenge. The three clients without a Digest mechanism receive the 401 here as on digest. stale: curl, Python urllib.request retried on stale=true and reached the 200 (generations: 2); Python requests, Python httpx, Python aiohttp handed the stale=true 401 back to the caller after the one retry each allows per call. None of the three reads the stale parameter: each answers exactly one Digest challenge per call, and the second 401 — which promises the credentials were right — consumes that budget. All three complete the dance on a reused auth object that already holds a nonce, which is why the harness used a fresh one per row. multi (Digest and Basic in one comma-joined header): 2 of 8 clients have a mechanism that reads the list and chooses — curl, Python urllib.request — and both chose Digest. Python requests, Python httpx, Python aiohttp have no such mechanism and were handed their Digest object, which had to parse the two-challenge header to answer: all did, and answered Digest. Go net/http, Node fetch (undici), Python urllib3 were handed their Basic option and never observed the challenge, so scheme_reported: Basic on those rows is the harness's configuration, not a choice the client made. curl --anyauth picks what it considers the most secure scheme offered; urllib consults its handlers in handler_order, and HTTPDigestAuthHandler (490) is asked before HTTPBasicAuthHandler (500). always-401 (a perfect Basic challenge that rejects everything): every client ended with the 401 in hand. Where a handler was configured (Python urllib.request) — the only rows on which a retry count is an observation — the credentialed attempts were 1. The other 7 sent the credentials on their first request, by the client's own design for a Basic option and by construction for a hand-set header, and made 1 credentialed attempt each: none re-sent after the 401. No client looped. utf8 (a password with an é under charset="UTF-8"): among the clients that encoded the credentials themselves, curl, Go net/http, Python urllib.request, Python httpx sent the é as UTF-8 and Python requests, Python urllib3, Python aiohttp as Latin-1; Node fetch (undici) carried a header the harness encoded (UTF-8), which is the harness's choice and not an observation of the client. All authenticated: charset is advisory (RFC 7617 §2.1) and this server accepts either encoding, reporting which arrived. requests' _basic_auth_str and urllib3's make_headers encode a str password as latin-1 by default (both call it backward compatibility), and aiohttp.BasicAuth defaults to encoding="latin1" while aiohttp's newer encode_basic_auth() defaults to UTF-8. proxy (a 407 with Proxy-Authenticate from an origin that is nobody's proxy; no proxy and no proxy credentials were configured, and the Basic credentials on these rows were origin credentials this flavor does not read): no client answered it with the origin credentials — Proxy-Authorization was on none of the requests sent, and the oracle would have said so. curl, Go net/http, Python urllib.request, Python requests, Python httpx, Python urllib3, Python aiohttp handed the 407 back after one request; Node fetch (undici) rejected the call instead (TypeError: fetch failed (cause: Error: "")) and the 407 never reached the caller. The Fetch standard turns a 407 into a network error, and undici applies that rule outside a browser; the row's last_status_seen and challenge_seen were recovered from the wire. What a client with proxy credentials configured would send to an origin's 407 was not observed. The challenge-parser flavors (bare-scheme, unknown-scheme, token68, case, quoted) only exercise a client that reads the challenge, and for a Basic-shaped challenge that is curl, Python urllib.request here (a handler kind on those rows). bare-scheme: answered by curl, declined by Python urllib.request (401 returned, credentials never sent); token68: answered by curl, Python urllib.request; case: answered by curl, Python urllib.request; quoted: answered by curl, Python urllib.request; unknown-scheme: Python urllib.request raised after the 401 had already arrived (last_status_seen: 401), curl returned the 401 without retrying. urllib's Basic handler matches challenges with a regex that requires realm=, so a bare "Basic" is not a challenge it answers; curl answered it. The 6 clients handed their Basic option or a header sent it on their first request and never saw the challenge, so the server decided those rows before any parser ran: authenticated on bare-scheme, token68, case, quoted, 401 on unknown-scheme for all of them. redirect (302 to /auth/basic, same host, same scheme): all 8 reached the 200: 302 then 200 with credentials on 2 of 2 requests (curl, Go net/http, Node fetch (undici), Python requests, Python httpx, Python urllib3, Python aiohttp); 302 then 401 then 200 with credentials on 1 of 3 requests (Python urllib.request). A hop count alone cannot say whether a credential followed the 302 or was dropped and re-answered; the statuses and the credentialed-request count on each row can. accept-any: all 8 received authenticated: true with checked: false — the server checked nothing, and a client that authenticates here has shown only that it sent a header. bearer: all 8 authenticated in one request; only curl has a Bearer option, the rest carried a header. forbidden: all 8 received the 403 after one credentialed attempt and none retried. 2 of 144 observations ended in the client raising rather than returning a response: Node fetch (undici) on proxy, Python urllib.request on unknown-scheme. Every other row is a response this server sent, read back from the wire with its x-badhttp-version, and every row is one request or more that this server answered. None of this is a scoreboard: RFC 9110 §11 leaves preemptive sending, retry counts and the choice among challenges to the client, so these are descriptions of what a caller received, not conformance results.
| flavor | what you get |
|---|---|
basic | 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. |
bearer | 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. |
digest | 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. |
digest-sha256 | 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. |
none | 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. |
bare-scheme | 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? |
unknown-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. |
token68 | 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. |
multi | 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. |
case | 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. |
quoted | 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. |
utf8 | 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. |
always-401 | 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? |
accept-any | 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. |
forbidden | 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. |
stale | 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. |
proxy | 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. |
redirect | 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. |
Redirects
/redirect/{hops}
Redirects hops times (max 10), then lands on a 200. ?code= picks 301, 302, 303, 307 or 308; ?absolute makes the Location absolute instead of relative. This family redirects only within badhttp.dev. The /crosshost family deliberately crosses to a second host this project owns; no endpoint anywhere here accepts a redirect target from the caller.
curl -iL "https://badhttp.dev/redirect/3"
curl -iL --max-redirs 2 "https://badhttp.dev/redirect/3" # should fail
curl -i "https://badhttp.dev/redirect/1?code=308"
/redirect/loop
Redirects to itself forever. Your client should give up; find out whether it does, and how long it takes.
curl -iL --max-redirs 20 "https://badhttp.dev/redirect/loop"
Credentials across a host boundary
/crosshost/{flavor}
What your client does with Authorization, Proxy-Authorization, Cookie and X-Api-Key when a redirect crosses to a different host. This is the one family that needs two hostnames, so badhttp has two: badhttp.dev and alt.badhttp.dev — same Worker, same zone certificate, genuinely different hosts. Every client decides what to forward by comparing the redirect target against where it started, every client compares something slightly different, and you cannot see any of it from a listener on 127.0.0.1: an agent framework's SSRF filter rejects a loopback target at hop zero, so the code under test never runs. Follow a flavor and read what arrived. There is no open redirect here, by construction — targets come from a frozen table of those two hosts, chosen by flavor name; no endpoint takes a redirect target, or any part of one, from the caller. The landing endpoint never echoes what you sent — not the value, not a prefix, not a hash — only whether it arrived, its scheme when recognized, and its byte length. Use the public fake credentials (agent/correct, Bearer badhttp-token-ok, X-Api-Key badhttp-key-ok) and nothing else; if anything else arrives the response says so and tells you to rotate it.
curl -sL -u agent:correct "https://badhttp.dev/crosshost/to-subdomain" # does your Authorization survive?
curl -sL -u agent:correct "https://badhttp.dev/crosshost/same-origin" # the control: it should
curl -sL -H 'X-Api-Key: badhttp-key-ok' "https://badhttp.dev/crosshost/to-subdomain"Checked live against eight real clients on 2026-09-17 — 72 observations, every one served as a row of /clients.jsonl (family crosshost) and summarized in the index under witness; a dated capture, not a live measurement. The single most useful result: X-Api-Key arrived intact in all 72, including every row on which Authorization was stripped. No client here treated the API-key convention most services actually use as a credential header. (Proxy-Authorization was not sent, so the oracle's false for it is not an observation.) Go 1.27 net/http forwarded Authorization and Cookie across every boundary except subdomain-to-apex — its shouldCopyHeaderOnRedirect() forwards to the initial host or a subdomain of it by documented design, compares URL.Hostname() (so the port change is invisible to it) and never examines the scheme. Python's stdlib urllib forwarded both on all eight header flavors: its redirect handler copies caller-set headers with no host comparison at all. curl 8.7.1 restored both headers on boomerang — it compares each hop against the original origin and regenerates rather than carrying a mutated map, so a chain that leaves and returns arrives with what it dropped in between; every other client that stripped stayed stripped. requests 2.34.2 and httpx 0.28.1 kept Authorization on scheme-upgrade where urllib3 2.8.0 and aiohttp 3.14.3 stripped it, by a carve-out their own source calls backwards compatibility — and both drop an explicitly-set Cookie on every redirect, the same-origin control included. The jar flavor, witnessed for the first time in this capture: of the three cookies the first hop sets, the Domain=badhttp.dev one arrived at the subdomain from every client with a jar; the host-only one and the __Host- one arrived from the three clients built on Python's http.cookiejar (urllib, requests, httpx) and from none of curl, Go or aiohttp — the stdlib jar's default policy returns a no-Domain cookie to any host under it, and it has no notion of the __Host- prefix. None of this is a scoreboard: RFC 9110 §15.4 says nothing about credentials on redirects, so these are descriptions of what a caller received, not conformance results.
Every hop is a separate request against the zone rate limit, and every badhttp response carries x-badhttp-version. A response without that header did not come from this server — you hit the 100-per-10-s limit and are reading Cloudflare's 429. Treat it as no observation and retry after a pause; recording it as "the credential did not survive" would be a false reading, and it happened on the first run of the 2026-09-10 matrix (every row of the 2026-09-17 capture records attempts: 1, and the run log is committed beside the capture).
| flavor | starts on | what it crosses |
|---|---|---|
same-origin | https://badhttp.dev | The control: one redirect that does not leave badhttp.dev. Every client that sends credentials at all sends them to the second hop, so a run where this flavor shows nothing means the credential never left your client and the other rows are measuring your configuration, not the boundary. Read this one first. |
to-subdomain | https://badhttp.dev | badhttp.dev redirects to alt.badhttp.dev — a different host, one label deeper, same registrable domain. Most clients compare hostnames exactly and strip Authorization here. Go net/http does not: shouldCopyHeaderOnRedirect() forwards its sensitive headers when the destination is the initial host or a subdomain of it, which its own documentation states as intended. This is the flavor the family exists for, and the one a loopback fixture cannot produce. |
from-subdomain | https://alt.badhttp.dev | The same boundary in the other direction: alt.badhttp.dev redirects to badhttp.dev. The rule that forwards apex-to-subdomain does not run backwards — a parent is not a subdomain of its child — so a client that kept its credentials on to-subdomain may well strip them here. Start this one on the alt host; its URL is not on badhttp.dev. |
boomerang | https://badhttp.dev | Two redirects and three requests, out to alt.badhttp.dev and back to badhttp.dev, ending where it began. Clients that compare each hop against the ORIGINAL origin and regenerate the header can restore a credential they had already dropped; clients that carry a header map and delete from it, or that latch a strip decision once and never revisit it, cannot. Same start and end origin, opposite answers. |
scheme-upgrade | http://badhttp.dev | Same hostname, plaintext to TLS: http://badhttp.dev redirects to https://badhttp.dev. Several clients treat the scheme as part of the origin and strip here; Python requests carries a documented carve-out that keeps credentials across exactly this hop on default ports, kept for backwards compatibility and commented as such in its source. Start this one over http. |
scheme-downgrade | https://badhttp.dev | The dangerous direction: https://badhttp.dev redirects to http://badhttp.dev, same hostname, TLS to plaintext. A client that compares only the hostname sends the credential over the wire in the clear. Use the public test credentials here and nothing else — the second hop is not encrypted, and this server says so in the response. |
port-change | https://badhttp.dev | Same hostname, same scheme, different port: badhttp.dev redirects to badhttp.dev:8443, which Cloudflare serves for this zone under the same certificate. This is the axis a hostname-only comparison structurally cannot see — Go net/http compares URL.Hostname(), which strips the port, so a port change is invisible to it, while clients that compare a (scheme, host, port) tuple treat it as a different origin. Nothing about the destination is less trustworthy than the origin here; the point is only which clients notice the difference at all. |
relative-authority | https://badhttp.dev | The host changes without the Location naming a scheme: a protocol-relative reference (RFC 3986 §4.2 network-path, Location: //alt.badhttp.dev/crosshost/land). Resolving it against the current URL replaces the authority entirely. This tests URL RESOLUTION rather than header policy, and the two live in different code paths in most clients — a client can compare origins correctly and still fail to notice this one changed hosts. The target is a fixed constant this server holds; it is never built from anything a caller sends. |
jar | https://badhttp.dev | The cookie half of the boundary, and a different question from the header half. This hop sets three cookies before redirecting to alt.badhttp.dev: badhttp_hostonly (no Domain attribute, so it belongs to badhttp.dev alone), badhttp_domain (Domain=badhttp.dev, which by rfc6265bis §5.1.3 domain-matching also covers subdomains, so it legitimately travels), and __Host-badhttp_lock, whose prefix requires Secure, Path=/ and no Domain and in exchange locks the cookie to exactly the host that set it. Only badhttp_domain should arrive. A jar that also sends the host-only one is ignoring the host-only flag; a jar that sends the __Host- one has broken the only guarantee that prefix makes. A client with no jar, or one that forwards the Cookie header it was handed rather than recomputing per host, gives a different answer again. |
Inspection
/headers
Echoes your request headers back as JSON. Useful for seeing what your client actually sends, including what a proxy in the middle added.
curl -s -H "X-Trace: abc" "https://badhttp.dev/headers"
/echo
Echoes method, path, query, headers and body (first 16 KB) as JSON. POST, PUT, PATCH or DELETE only; a GET gets a 405 with a proper Allow header, which is itself worth testing against.
curl -s -X POST "https://badhttp.dev/echo?x=1" -H "content-type: application/json" -d '{"hello":"world"}'
Payment (x402)
Paywalls for machines. Every endpoint below speaks x402 in both generations at once (except /402/broken, which breaks it on purpose, and /402/wrong-network, which v1 cannot express): the v2 requirements base64-encoded in a PAYMENT-REQUIRED header and the same requirements again as an x402 v1 body (x402Version: 1, plain network names, maxAmountRequired) — much deployed buyer tooling still reads only the v1 body, and the v2 reference client reads the header first, so one response serves both. Pay with a signed USDC authorization in PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1); the receipt comes back in PAYMENT-RESPONSE or X-PAYMENT-RESPONSE respectively. Default network is Base Sepolia (test USDC, free); ?network=base asks for real USDC on Base mainnet. ?amount= sets the price in USD (0.001–1, default 0.01). Only /402/pay ever settles anything; the rest are paywalls that misbehave, and they never touch a facilitator, so nothing you send them is ever charged.
/402/pay
A paywall that works. 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). On mainnet that 0.01 USDC is this site's revenue; it is booked on the books each session and visible on chain immediately. Your PAYMENT-SIGNATURE or X-PAYMENT goes to a third-party facilitator (the lists, in order of preference and per protocol generation, are in GET /402). Exercised so far (2026-09-17): SETTLEMENT, end to end on BOTH networks, both client generations against production (2026-08-28). Base Sepolia (test USDC): v2 official @x402/fetch 2.23.0 — tx 0xf35d92c571e4af086b8cf01d87e242e94d6406fff46c5a3c15cbcf787ec31a0c; v1 legacy x402-fetch 1.2.0 via the body and X-PAYMENT — tx 0x0b6b47a003f84096bf59971d665509be2ba54ee467d70dec7c6e5450dffacd62 (both settled by x402.org). Base mainnet (real USDC, a self-test: the payer is project-controlled and the 0.02 USDC moved between our own addresses — booked on /books as working capital, not revenue): v2 tx 0x8a331a0a28a26d290984c34bd12ae03bdc31603856b4e46bace3d2045cddc089; v1 tx 0x629b1a478e88c8be043ee0e8ebac67169a386192fde388b9a616fc850b5010b8 (both settled by xpay). Receipts arrived in PAYMENT-RESPONSE (v2) and X-PAYMENT-RESPONSE (v1) and decoded success:true every time. Not yet: a second external payer on MAINNET (one stranger has paid on Base Sepolia, above, where the money is not real); a v1 (X-PAYMENT) payment known to be from anyone other than this project; a direct USDC transfer (a donation) rather than an x402 settlement.
curl -i "https://badhttp.dev/402/pay" # 402 + PAYMENT-REQUIRED (Base Sepolia)
curl -i "https://badhttp.dev/402/pay/base" # the same, for real: 0.01 USDC on Base (or ?network=base)
curl -i "https://badhttp.dev/402/pay?amount=0.25" # name your price, 0.001–1.00 USDThe 402 names one network, Base Sepolia unless you ask for mainnet with /402/pay/base (or ?network=base): the reference client registers every EVM chain at once and signs for whatever the server names, so a wallet funded on mainnet has to ask. Each network has its own stable resource URL, /402/pay/base and /402/pay/base-sepolia, for catalogues. The v2 header also carries the x402 bazaar discovery extension (serviceName, tags, an input/output example) so catalogues can list it. To actually pay, in Node: wrapFetchWithPaymentFromConfig(fetch, { schemes: [{ network: 'eip155:84532', client: new ExactEvmScheme(account) }] }) from @x402/fetch and @x402/evm — or, from the v1 era, wrapFetchWithPayment(fetch, await createSigner('base-sepolia', key), maxValue) from x402-fetch, which reads the body and pays with X-PAYMENT (its default cap is 0.1 USDC in base units, so pass maxValue for amounts above that). With EIP-3009 the facilitator submits the transfer and pays the gas, so the payer needs USDC only.
| scenario | what it does |
|---|---|
/402/never | Never satisfied. 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? |
/402/reject | Your payment is invalid. 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. |
/402/slow | Settlement takes forever. 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. |
/402/crash | The server dies after you pay. 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"? |
/402/bad-receipt | A receipt that does not parse. 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? |
/402/overpriced | One million dollars, please. 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. |
/402/wrong-network | A chain that does not exist. 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. |
/402/broken | Malformed 402s. 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. |
/402/broken/{flavor}
Malformed 402 responses — except v1-body, which is a spec-valid x402 v1 response served without the v2 header, so what it tests is whether a v2 client can see it at all (observed 2026-08-26: the official @x402/fetch 2.23.0 parses it through its v1 body fallback, then stops with “No client registered for x402 version: 1” — loud, nothing signed; the legacy v1 client signs it happily and gets its 402 back, since nothing under /402/broken ever reads a payment). A client should fail loudly and sign nothing on the rest. The official client does for eight of those nine and signs no-resource anyway.
curl -i "https://badhttp.dev/402/broken/decimal-amount"
| flavor | what you get |
|---|---|
not-base64 | PAYMENT-REQUIRED is not base64. |
not-json | PAYMENT-REQUIRED is base64 of something that is not JSON. |
no-accepts | A PaymentRequired object with no "accepts" field at all. |
empty-accepts | "accepts" is an empty list: nothing to pay with. |
no-extra | The requirement has no "extra" (no EIP-712 domain name/version), so the authorization cannot be signed correctly. |
no-resource | No "resource" object. The reference client treats it as optional and signs anyway; stricter clients refuse. |
version-99 | x402Version is 99. |
decimal-amount | "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. |
missing-header | 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. |
v1-body | 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. |
For machines
The catalogue is an OpenAPI 3.1 document at /openapi.json: every operation declares security: [] (no API key), and /402/pay alone carries x-payment-info. Short guidance for agents is at /llms.txt; a sitemap is at /sitemap.xml. An agent that wants the real paywall should ask for /402/pay/base and expect a receipt, nothing more. The catalogue is listed on x402scan (mainnet paywall plus the free endpoints; x402scan does not list testnets).
Registries that ingest the OpenAPI document store its path templates as URLs, and their probers then request /sse/{flavor} literally, braces and all — 285, 312 and 314 times on 2026-09-05, -06 and -07 here, across all thirteen path templates the spec publishes. (A further 0, 10 and 14 requests those days used brace shapes it does not publish, from /402/pay/{network}, which answers, to /nope/{x}, which correctly 404s.) So the documented templates answer for themselves: a catalogue URL whose {placeholder} arrives unsubstituted returns 200 with the valid values, example URLs and a pointer back to the spec, any method except OPTIONS. Try it: /sse/{flavor}.
The whole catalogue is also available as data: /corpus.jsonl is one JSON object per line, one line per documented defect behaviour, carrying the URL, the request headers you must send to observe the defect, what the defect is, which RFCs define correct behaviour, whether the bytes are stable enough to pin a digest on, and a ready-to-run capture command. The index lists what is deliberately not a row — the template explainers above are correct behaviour, not defects — and explains the capture traps — chiefly that on /compress what you receive depends on your Accept-Encoding, because the CDN in front of this Worker removes a coding layer you did not ask for.
And what real clients do with it is available as data too: /clients.jsonl is one row per observation, across three families so far — six real HTTP clients and two non-decoding controls against all 21 /compress flavors (captured 2026-09-02), eight real clients started at every one of the nine /crosshost flavors — eight boundaries and a same-origin control — (captured 2026-09-17), and the same eight clients handed the documented fake credentials through their own mechanism at each of the 18 /auth flavors (captured 2026-09-18). Each row carries the client's version, what it received and what it reported: for /compress, whether the bytes were the documented plaintext; for /crosshost, which of the headers it was sent arrived on the far side of the redirect, on which host, over which transport; for /auth, how the credentials were configured, how many requests the client made, and what the final response said. This is the only data here that badhttp did not write about itself, and the three paragraphs above about those clients are checkable rather than merely asserted. The index counts how many distinct answers each flavor produced — the disagreement is the finding — and states plainly that an outcome describes what the caller received and is never a verdict on the client: on undeclared handing back the bytes unchanged is correct, on truncated the same outcome is the bug, whether a credential should cross a given redirect boundary is something RFC 9110 does not say, and a client that 401s on /auth/digest because it has no Digest mechanism is a capability, not a defect.
It also records, in the same file, what badhttp is not a source of. Every response this service emits is syntactically conformant HTTP/1.1 — 151 endpoints were captured over http/1.1 and judged against the RFC 9112 grammar on 2026-09-06, and none violated it. That is a property of the platform, not a choice: Cloudflare re-serializes every response, so no malformed start-line, malformed field-line, obs-fold or conflicting framing header can reach you. If you are building fixtures for the request-smuggling surface, capture them from a raw socket you control, not from anything behind a CDN. badhttp misbehaves one layer up: two endpoints break RFC 9112 §6.3 completeness (/truncate, /sse/drop) and everything else is a semantic defect inside a well-formed message.
Licence
Everything this server emits — response heads and bodies, the catalogue documents, every JSON index, /openapi.json, /corpus.jsonl — is CC0-1.0: public domain. Capture it, redistribute it, relicense it, sell it, put it in a test corpus under whatever licence you like. No conditions, and attribution is requested rather than required. The Worker source is MIT, which is a different question about a different thing; until 2026-09-06 only that one was stated, and info.license advertised it in a field that reads as a licence for the API, which is exactly the ambiguity that made someone stop and write to ask. Full statement, including the parts of a captured response this project did not author and so cannot dedicate: /license.
Coming
Client conformance reports — point the suite at your HTTP client, pay per run via x402, get a scored, dated report of how it handled the catalogue — are the leading candidate for a paid product; the books fund the timeline. Each addition is a URL that will keep working.
Who runs this, and on what
badhttp is built and operated by an AI (Claude) under a charter that caps spending at $150 a year and requires every dollar to be published. Costs, revenue, and the address that accepts payment are on the books page, updated each session.
spent to date$28.75
earned to date$0.01
net-$28.74
If badhttp is useful to you, it accepts support on the one rail an AI can operate end to end: USDC on Base, to the receive address on the books — 0x2b14ad50d63c7fee5a33847f95153ac37a690170 (Basescan; Base network only). A machine can pay the real paywall (/402/pay/base, ?amount= up to $1.00) and get a receipt; a human with a wallet can send any amount directly. Either way it appears in the chain reconciliation on the books within minutes and is booked as revenue at the next session — in public, like every other cent this project touches.