A parser and serializer for HTTP WWW-Authenticate, Proxy-Authenticate, and Authorization header values, per RFC 9110 section 11.
import { parseChallenges } from 'wwwauth';
const header = 'Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"';
const challenges = parseChallenges(header);
challenges.length; // 2
challenges[0].scheme; // "Newauth"
challenges[0].params.get('title'); // 'Login to "apps"'
challenges[1].scheme; // "Basic"
challenges[1].params.get('realm'); // "simple"RFC 9110 section 11.6.1 gives this exact header as its worked example of
WWW-Authenticate, and says it is two challenges: Newauth (with realm,
type, and title) and Basic (with realm). The reason it's tricky: a
challenge's own auth-params are comma-separated, at the same syntactic level
as the commas that separate one challenge from the next -- you cannot split
on top-level commas alone; you have to notice that Basic realm="simple"
does not fit the shape of a continuing name=value pair.
Every parser checked gets this wrong, in three different ways:
auth-header@1.0.0merges both challenges into one, filingBasicas a second value of therealmparameter and losing theBasicscheme entirely as atoken:{ scheme: 'Newauth', params: { realm: ['apps', 'simple'], type: '1', title: 'Login to "apps"' }, token: 'Basic' }www-authenticate@0.6.3throws outright on this exact header (its tokenizer chokes on the escaped quote once the parameter list runs past the first challenge):{"scheme":"","parms":{},"err":"Unexpected token (apps\\) after Login to \\\""}. On a header without that escape it doesn't throw, but still folds the second challenge in under a garbage key:{ scheme: 'Digest', parms: { realm: '...', qop: '...', nonce: '...', 'Basic realm': 'simple' } }.bearerparse(github.com/prasad-a-abhishek/bearerparse, no npm package) silently drops the second challenge:{ scheme: 'newauth', params: { realm: 'apps', type: '1', title: 'Login to "apps"' } }-- no trace ofBasicanywhere, and no error.
Also checked and not covering: header-parsers@0.6.0 (labeled RFC 9110,
but only parses Authorization, no WWW-Authenticate/challenge parsing at
all); auth-param-parser@1.0.1 (parses a bare auth-param list only --
throws given a scheme-prefixed header, so it can't parse a challenge at
all, only an already-isolated param list); express-authentication-header
(vendors the same auth-header package and inherits its bug). Node core
and undici@8.11.2 have no WWW-Authenticate parser to check. Chromium's
network stack (net/http/http_auth_challenge_tokenizer.{h,cc}, read via
gh api against the klzgrad/naiveproxy mirror) sidesteps the problem
rather than solving it: it marks WWW-Authenticate/Proxy-Authenticate as
non-coalescing headers (http_response_headers.cc's
kNonCoalescingHeaders), so repeated header lines are kept separate, but
within one header line its tokenizer takes only the first space-separated
token as the scheme and treats the rest of that one line as an opaque
blob -- it does not attempt to split multiple challenges appearing on a
single line at all. In practice this works because servers overwhelmingly
send one challenge per header line; it would mishandle the RFC's own
Newauth/Basic example if a server sent it as written, on one line.
Measured: node bench/compare-incumbents.mjs (1000 generated cases
from the same generator as the round-trip test, each server-reachable
package installed with npm install --no-save auth-header@1.0.0 www-authenticate@0.6.3, then npm run build):
Corpus: 1000 generated cases (same generator as the round-trip test).
wwwauth 1000/1000 exact-shape matches
auth-header@1.0.0 81/1000 exact-shape matches (335 threw)
www-authenticate@0.6.3 25/1000 exact-shape matches (57 threw)
bearerparse (github) 83/1000 exact-shape matches
RFC 9110 11.6.1 flagship example:
wwwauth: ["Newauth","Basic"]
auth-header: {"scheme":"Newauth","token":"Basic"}
www-authenticate: {"scheme":"","parmKeys":[]}
bearerparse: {"scheme":"newauth","params":{"realm":"apps","type":"1","title":"Login to \"apps\""}}
The "exact-shape match" metric only requires the right number of challenges plus the first challenge's scheme and param count -- a much lower bar than wwwauth's own full-structure round-trip (also 1000/1000, and 5000/5000 in the committed test), so these numbers are generous to the incumbents, not harsh.
Go's net/http and golang.org/x/net have no WWW-Authenticate/challenge
parser to differential-test against (checked gh search code and the
package docs). Python's stdlib does: urllib.request.AbstractBasicAuthHandler
parses Basic realms out of WWW-Authenticate with a hand-rolled regex
(re.compile('(?:^|,)\s*([^ \t]+)\s+realm=(["\']?)([^"\']*)\2', re.I)).
Measured directly (Python 3, stdlib only):
>>> h.rx.findall('Basic charset="UTF-8", realm="simple"')
[] # fails when realm isn't the parameter immediately after the scheme
>>> h.rx.findall(r'Basic realm="a \"b\" c"')
[('Basic', '"', 'a \\')] # truncates at the first embedded quote; no quoted-pair handling
wwwauth handles both correctly (auth-param order is never assumed, and
\" inside a quoted-string decodes to "), which is unsurprising given
Python's version is a one-line regex built for a narrower job (finding a
Basic realm for its own auth handler), not a general challenge parser.
npm install wwwauthNode >= 20.6. No runtime dependencies.
import { parseChallenges } from 'wwwauth';
const challenges = parseChallenges(response.headers.get('www-authenticate'));
for (const c of challenges) {
if (c.scheme.toLowerCase() === 'bearer') {
console.log(c.params.get('error'), c.params.get('scope'));
}
}Pass an array to handle a header sent as several repeated field lines
(e.g. IncomingMessage.headers['www-authenticate'] when a Node server
process combined them, or your own collection of raw header lines). Each
element is parsed independently and the results are concatenated --
deliberately not joined with a comma first (see "How it works").
import { basicCredentials } from 'wwwauth';
request.headers.set('authorization', basicCredentials('alice', 'hunter2'));import { parseBearerChallenge, parseBasicChallenge, parseDigestChallenge } from 'wwwauth';
parseBearerChallenge(challenge); // { realm?, scope?, error?, error_description?, error_uri? }
parseBasicChallenge(challenge); // { realm?, charset? } -- throws if charset isn't "UTF-8"
parseDigestChallenge(challenge); // { realm?, domain?, nonce?, opaque?, stale?, algorithm?, qop?, charset?, userhash? }parseChallenges(header, { mode: 'strict' }); // throws AuthHeaderParseError on any deviation from the grammar
parseChallenges(header, { mode: 'lenient' }); // the default -- recovers, see "Lenient mode" belowParses a WWW-Authenticate or Proxy-Authenticate header value.
input is a string or string[] (repeated header instances).
Serialize one challenge, or a list back to one header value (joined with ", ").
Quotes a param value only when it is not already a valid bare token. Throws
RangeError if a challenge carries both token68 and params (the grammar
allows only one), or if a value contains an octet that cannot be represented
in a quoted-string at all (a raw control character other than HTAB).
Same grammar engine, for the single credentials value in an
Authorization/Proxy-Authorization header rather than a list of challenges.
| Field | Type | Description |
|---|---|---|
scheme |
string |
As written on the wire. Compare with .toLowerCase() -- RFC 9110 11.4 says auth-scheme is case-insensitive. |
token68 |
string | undefined |
Present for schemes using the opaque-blob form (legacy Basic, Bearer, Negotiate/NTLM). Mutually exclusive with params. |
params |
ParamList |
Ordered [name, value][], plus .get(name), .getAll(name), and .duplicates. |
ParamList is a real Array, not a subclass of one (see "How it works").
.get/.getAll compare name case-insensitively; .get returns the first
match. .duplicates lists (lowercased) names that occurred more than once --
RFC 9110 11.4 says each parameter name MUST occur only once per challenge,
but this library keeps every occurrence rather than silently dropping one,
and surfaces the repeats here so a caller can decide how to react.
| Name | Type | Default | Description |
|---|---|---|---|
mode |
'strict' | 'lenient' |
'lenient' |
'strict' throws AuthHeaderParseError on any grammar deviation or duplicate param name. 'lenient' recovers -- see below. |
parseBearerChallenge(challenge) => { realm?, scope?, error?, error_description?, error_uri? }bearerCredentials(token) => string-- buildsBearer <token>, validating theb64tokengrammar.parseBearerCredentials(credentials) => string-- extracts the token from parsed Authorization credentials.
parseBasicChallenge(challenge) => { realm?, charset? }-- throws ifcharsetis present and isn't"UTF-8"(case-insensitively), the only value RFC 7617 section 2.1 permits.basicCredentials(userid, password) => string-- throws ifuseridcontains a colon (forbidden by RFC 7617 section 2).parseBasicCredentials(credentials) => { userid, password }
parseDigestChallenge(challenge) => { realm?, domain?, nonce?, opaque?, stale?, algorithm?, qop?, charset?, userhash? }domainis split on whitespace;qopis split out of its quoted comma-separated form (qop="auth,auth-int"->['auth', 'auth-int']);stale/userhashare booleans. This library does not compute a Digest response.
AuthHeaderError-- base class.AuthHeaderParseError-- thrown in strict mode; carries.item, the raw list element that failed, when known.
The header value is first split into its top-level comma-separated list
items, ignoring commas inside a quoted-string (RFC 9110 5.6.1's list
syntax; empty items from leading/trailing/doubled commas are dropped, as
the RFC requires tolerating). Each item is then classified independently:
- If a challenge is already open (and isn't a
token68challenge) and the item matchesauth-param(token BWS "=" BWS (token / quoted-string)) in full, it's a continuation parameter of that challenge. - Otherwise the item must match
auth-scheme [1*SP (token68 / auth-param)]in full: it starts a new challenge. A token immediately followed by=(no space) can never reach this branch, sotype=1is never mistaken for a scheme namedtype; converselyBasic realm="simple"fails step 1 (there's a bareBasictoken before any=) and is correctly read as a new scheme with one param.
ParamList is a plain Array with get/getAll/duplicates attached via
Object.defineProperty (non-enumerable, so JSON.stringify stays a clean
array of pairs) rather than a subclass of Array. Subclassing looks more
natural but breaks the first time a caller calls .filter()/.map()/
.slice() on it: those methods construct a new instance of the subclass
through Symbol.species, calling new ParamList(someLength) with a bare
number, which does not match a constructor that expects a list of entries.
Repeated header instances (parseChallenges(string[])) are parsed one
value at a time and concatenated, never joined into one string with a
comma first -- matching Chromium's choice to treat this header as
non-coalescing (see "Why"). Joining first risks one line's trailing bare
scheme or token68 bleeding into the next line's first item.
Lenient mode (the default) recovers from two specific defects rather than throwing:
- A list item that is neither a valid continuation param nor a valid new challenge start is dropped, and parsing continues with the rest of the header. No incumbent or reference implementation checked models this exact recovery rule (Chromium's tokenizer, read for this project, simply doesn't attempt multi-challenge splitting within one header line at all, so there's nothing there to diverge from on this point) -- it follows directly from RFC 9110's own guidance that recipients should tolerate unknown or malformed extension content rather than fail the whole header.
- An unterminated
quoted-string(a"with no matching close before the item ends) is recovered by treating the remainder of the item as the value, matching the general "accept what you can, don't reject the whole field" posture user agents take toward this header rather than any one library's specific behavior.
Strict mode disables both recoveries and additionally rejects a duplicate parameter name within one challenge (RFC 9110 11.4's MUST).
quoted-stringvalues are handled as JavaScript strings (UTF-16), not raw octets. A byte in theobs-textrange (0x80-0xFF) that isn't valid UTF-8 on its own will round-trip incorrectly; RFC 9110 itself only definesobs-textfor historical compatibility and recommends against producing it.serializeChallengecannot represent a value containing a raw control character (other than HTAB) -- there is no legalquoted-pairfor it; it throwsRangeErrorrather than emit invalid output.- Digest support is parse-only: no response computation, no
nc/cnoncegeneration. Building Digest credentials is out of scope for this package. - No built-in credential storage, retry loop, or
fetch/http.requestintegration -- this library only parses and serializes header values. - The lenient-mode "drop the bad item and continue" rule was designed against the RFC's own examples and the generated corpus below, not against a specific browser's exact behavior for every malformed input; see "Lenient mode" above for what was and wasn't checked against Chromium's source.
Tested on Node 24.20.0 (macOS, arm64) via node --test, and the built
dist/ smoke suite on the same Node version. test.yml also runs the
dist/ smoke suite on Node 20.6 and 22 in CI; those runtimes were not
exercised locally for this package.
npm install
npx tsc --noEmit
npm test # TypeScript suite, node --test
npm run build && npm run test:dist # what the oldest supported runtimes get
npm install --no-save auth-header@1.0.0 www-authenticate@0.6.3
npm run bench # incumbent comparison (bearerparse fetched automatically, pinned+sha256'd)MIT. See LICENSE.