{
  "flavors": {
    "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."
  },
  "usage": "/auth/{flavor}, any method (the request body is never read; HEAD gets the same status and headers)",
  "credentials": {
    "basic": {
      "user": "agent",
      "password": "correct"
    },
    "utf8": {
      "user": "agent",
      "password": "sésame"
    },
    "bearer": {
      "ok": "badhttp-token-ok",
      "limited": "badhttp-token-limited"
    },
    "note": "These are the only values any checking /auth flavor accepts; /auth/accept-any returns authenticated:true for any non-empty Authorization header without checking it."
  },
  "warning": "The test credentials are public and fake: user 'agent', password 'correct' (Bearer: badhttp-token-ok). Never send real credentials or point a production credential store at badhttp — nothing here is protected, and anything received is compared in memory, then discarded: never stored, logged, or echoed (the one exception: a successful Digest's Authentication-Info repeats your cnonce and nc, as RFC 7616 §3.5 requires).",
  "witness": {
    "measured": "2026-09-18T18:18:33Z",
    "observations": 144,
    "data": "https://badhttp.dev/clients.jsonl",
    "data_note": "Every observation is a row of /clients.jsonl with family \"auth\", joined to /corpus.jsonl by corpus_id; /clients indexes them with the mechanism legend, the outcome legend and the per-flavor disagreement. The findings below are a reading of those rows, not a second source.",
    "what_this_is": "Eight real HTTP clients, each handed the documented fake credentials through its own mechanism for the flavor's scheme, run against these exact URLs on this exact date. It is a DATED CAPTURE, not a live measurement, and it describes WHAT THE CALLER RECEIVED — it is never a verdict on a client. Which mechanism a client was handed was fixed per flavor by the harness, not chosen by the client; a client with no Digest mechanism was sent with no credentials on the Digest flavors, and its 401 there is a capability, not a defect. RFC 9110 §11 leaves preemptive sending, retry counts and the choice among several challenges to the client. Re-run it and move the date rather than letting it stale.",
    "clients": [
      "curl 8.7.1",
      "Go net/http go1.27.0",
      "Node fetch (undici) node v26.8.1 / undici 8.10.0",
      "Python urllib.request python 3.14.7 stdlib",
      "Python requests 2.34.2 (python 3.14.7)",
      "Python httpx 0.28.1 (python 3.14.7)",
      "Python urllib3 2.8.0 (python 3.14.7)",
      "Python aiohttp 3.14.3 (python 3.14.7)"
    ],
    "findings": [
      "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 Basic-only challenge handler in this roster, and every other client's Basic option (curl's -u included) sets the header before the first request (the stdlib also offers HTTPPasswordMgrWithPriorAuth for that form, not used here); curl --anyauth, used on the parser flavors, also waits for the challenge.",
      "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 have no WWW-Authenticate handling: 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. 0 of 144 observations ended in a transport failure with nothing usable returned. Every other row is a response this server sent, read back from the wire with its x-badhttp-version, and is one request or more that this server answered."
    ],
    "what_the_oracle_cannot_tell_you": "A /auth response says what arrived on ONE request. It cannot say how many requests the client made to get there, which of them carried credentials, or whether the client would have retried: those come from the harness's own side, which is why every row carries hops (status and header presence per request the client sent), requests_made and credentialed_requests, and names the layer at which they were counted. It also cannot say what the client was configured with, which is why every row carries mechanism_kind and mechanism, and a table built from response bodies alone would be unverifiable.",
    "reproduce": "Configure the row's client the way its mechanism field says, with the documented credentials from this index and nothing else, ask for the flavor url, and compare the status and body with the row; count the requests your client sent. Pace the calls against the zone limit of 100 per 10 s, and treat a response without x-badhttp-version as no observation. The harnesses that produced the rows are in scripts/auth-witness/ in the source."
  }
}
