badhttp v0.11.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 "http://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 "http://badhttp.dev/status/429?retry-after=3"
curl -i "http://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 "http://badhttp.dev/delay/3"      # should time out
curl -m 5 "http://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 "http://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 "http://badhttp.dev/flaky/50?fail=503"
for i in 0 1 2 3; do curl -s "http://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 "http://badhttp.dev/badjson/trailing-comma"
curl -si "http://badhttp.dev/badjson/html?code=502"
flavorwhat you get
truncatedCut off mid-stream; Content-Length matches what was sent.
trailing-commaTrailing comma. Valid JSON5, invalid JSON.
single-quotesSingle-quoted strings. Python repr, not JSON.
nanNaN and Infinity literals. Python json.dumps emits these by default.
bomUTF-8 byte order mark before the JSON. Some parsers choke.
htmlAn HTML error page served with Content-Type: application/json and a 200.
mislabeledPerfectly valid JSON served as text/html.
emptyZero-byte body with Content-Type: application/json and a 200.
unterminatedUnterminated string with an escaped quote inside.
bigintAn integer above 2^53 and a number above double range. Precision loss or Infinity in most JS parsers.
duplicate-keysDuplicate keys. Last-one-wins in most parsers, but not all.
commentsJSONC comments.
leading-garbageA stray line before the JSON document.
concatenatedTwo JSON documents back to back with no separator (not NDJSON, no newline).
deep5,000 levels of nested arrays. Recursive parsers may blow the stack.
utf16Valid 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 "http://badhttp.dev/truncate?length=1000&send=500" -o /dev/null

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 "http://badhttp.dev/sse/ok"
curl -N "http://badhttp.dev/sse/split-utf8" | xxd | tail -3
curl -sN --http1.1 "http://badhttp.dev/sse/drop"; echo "exit $?"      # 18: closed with bytes outstanding

Every 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.

flavorwhat you get
okA correct stream, for comparison: retry, id, event and data fields, blank-line delimited, then a clean close. ?events= and ?interval= (ms).
stallHeaders 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.
cutEnds 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.
dropThe 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.
crlfEvery line ends in CRLF. The spec allows CR, LF or CRLF; parsers that split on "\n" leave a trailing "\r" in every value.
crEvery line ends in a bare CR. Spec-legal. Almost nobody handles it.
no-spaceField 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".
multilineMultiple 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.
commentsA 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-utf8Multi-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-typeA valid stream served as text/plain. A browser EventSource must fail; does your client notice?
error-eventAn event whose name is "error". EventSource routes it to onerror beside real transport failures; does your client tell them apart?
bigOne event with a ?bytes= (default 64 KiB, max 1 MiB) data line. Line buffers with a fixed cap truncate or crash.
resumeSends 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 "http://badhttp.dev/range/ok?length=512"
curl -s -r 128-255 "http://badhttp.dev/range/shifted" | head -2   # look at the offsets: they are wrong
curl -C 320 -o resumed.txt "http://badhttp.dev/range/ignore"       # curl refuses: the server sent 200

Checked 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.

flavorwhat you get
okThe 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.
ignoreNo 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-onlyAdvertises 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-oneTreats 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.
shiftedServes 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-prefixThe 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-zeroAcknowledges 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-totalCorrect 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-rangeA 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-206A 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-rangeHonors 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-416Every Range request gets 416 with Content-Range: bytes */{length}; without Range, a 200. Tests give-up-and-restart logic.
unknown-totalCorrect 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-ignoredThe 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 "http://badhttp.dev/etag/ok" && curl -si --etag-compare t.txt "http://badhttp.dev/etag/ok"   # second is 304
curl -si -H 'If-None-Match: "e-badhttp-1"' "http://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.

flavorwhat you get
okThe 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.
weakThe 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.
changingA 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.
ignoreSends 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-304Every 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.
mismatchRevalidation "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-304If-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.
unquotedThe 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-dateNo 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.
futureNo 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 "http://badhttp.dev/cookies/ok" && curl -s -c jar -b jar "http://badhttp.dev/cookies/echo"
curl -sL -c jar2 -b jar2 "http://badhttp.dev/cookies/on-redirect"      # does the 302's cookie survive?
curl -s -b 'made=up; made=up-again' "http://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).

flavorwhat you get
okThe control, fully correct and minimal: badhttp_ok=1; Path=/cookies; Max-Age=3600. Store it, return it to /cookies/* for an hour.
echoThe 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).
foldedTwo 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.
duplicateThe 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-redirectA 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.
deleteThe 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-expirybadhttp_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-expiresExpires 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-futureExpires 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-domainDomain=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-suffixDomain=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.
domainThe 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-prefixPath=/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-prefixesFour 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.
quotedbadhttp_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?
utf8A 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.
namelessTwo 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.
hugeOne 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 "http://badhttp.dev/auth/basic"
curl --digest -u agent:correct "http://badhttp.dev/auth/digest"
curl -u agent:correct "http://badhttp.dev/auth/always-401"   # rejected anyway; how often does your client retry?
curl -H 'Authorization: Bearer anything-at-all' "http://badhttp.dev/auth/accept-any"

Checked live against three real clients (2026-08-27). curl 8.7.1: plain -u sends Basic preemptively (so /auth/none succeeds), --anyauth picks Digest from the multi challenge, --digest completes the stale dance without re-prompting, makes exactly one credentialed attempt at always-401 and none after the 403, follows the 302 with credentials attached, and sends agent:sésame as UTF-8. Python 3.14 urllib: never sends preemptively (/auth/none is unreachable for it), answers the case/quoted/multi/token68 challenge traps correctly, refuses the realm-less bare-scheme challenge, honors stale=true, stops after one credentialed attempt at always-401, and dies loudly at SHA-256 (ValueError: Unsupported digest authentication algorithm). requests 2.34.2: sends Basic preemptively, speaks SHA-256 — but encodes agent:sésame as Latin-1 despite charset="UTF-8" (the /auth/utf8 body reports it) and gives up on the stale dance: its digest handler counts the stale=true 401 as a second failure and stops where curl and urllib retry to the 200. Our Authentication-Info rspauth is verified by independent recomputation on every smoke run; none of these clients checks it.

flavorwhat you get
basicThe 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.
bearerThe 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.
digestThe 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-sha256The same correct Digest with algorithm=SHA-256 (RFC 7616's preferred). Some clients only speak MD5 and fail here — how loudly is the test.
noneA 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-schemeWWW-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-schemeA 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.
token68One 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.
multiOne 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.
caseThe 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.
quotedThe 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.
utf8Basic 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-401A 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-anyThe 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.
forbiddenagent: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.
staleThe 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.
proxyAn 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.
redirectA 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. Redirects only point back at this host, never anywhere else.

curl -iL "http://badhttp.dev/redirect/3"
curl -iL --max-redirs 2 "http://badhttp.dev/redirect/3"   # should fail
curl -i "http://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 "http://badhttp.dev/redirect/loop"

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" "http://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 "http://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-08-28): 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 payment from anyone other than this project — the first such payment is the first revenue.

curl -i "http://badhttp.dev/402/pay"                    # 402 + PAYMENT-REQUIRED (Base Sepolia)
curl -i "http://badhttp.dev/402/pay/base"               # the same, for real: 0.01 USDC on Base (or ?network=base)
curl -i "http://badhttp.dev/402/pay?amount=0.25"        # name your price, 0.001–1.00 USD

The 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.

scenariowhat it does
/402/neverNever 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/rejectYour 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/slowSettlement 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/crashThe 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-receiptA 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/overpricedOne 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-networkA 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/brokenMalformed 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 "http://badhttp.dev/402/broken/decimal-amount"
flavorwhat you get
not-base64PAYMENT-REQUIRED is not base64.
not-jsonPAYMENT-REQUIRED is base64 of something that is not JSON.
no-acceptsA PaymentRequired object with no "accepts" field at all.
empty-accepts"accepts" is an empty list: nothing to pay with.
no-extraThe requirement has no "extra" (no EIP-712 domain name/version), so the authorization cannot be signed correctly.
no-resourceNo "resource" object. The reference client treats it as optional and signs anyway; stricter clients refuse.
version-99x402Version 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-headerA 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-bodyA 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 — some six hundred times a day here. 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}.

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$23.75
earned to date$0.00
net-$23.75