The silent privilege-creep in three popular OIDC libraries — and why your staging cluster already leaked.
We audited passport-openidconnect, openid-client, and auth0-spa-js against the full OAuth 2.1 threat model. Two shipped mitigations we couldn't verify. One quietly disabled at_hash validation in v4.2. Here's the tape.
§1 · Context
OpenID Connect turned sixteen last year. The spec is finally boring — which
is a compliment — and yet the libraries most teams reach for keep finding
new ways to get it wrong. This is not an indictment of the spec. It is a
report on the state of its JavaScript implementations, measured against a
threat model any adversary can read in public.
We picked three libraries that between them account for roughly 71% of
weekly OIDC downloads on npm, as measured in the first quarter of 2026.
Two are maintained in earnest. One is maintained by a vendor whose
incentives are not aligned with yours, and the difference shows up in the
code.
"The defaults you ship are the threat model 95% of your users will ever
see." — Engineering folk wisdom, unattributed
§2 · Method & scope
For each library we performed: (a) a line-by-line read of the
authentication flow, (b) a differential run of our OAuth 2.1 compliance
corpus — 148 machine-checkable assertions lifted from RFCs 6749, 7636,
9126 and the OAuth 2.1 draft — and (c) a three-day fuzz of ID-token
handling using a deliberately misbehaving identity provider we maintain
for exactly this purpose.
We scored against six axes, with caps: auth correctness,
crypto correctness, supply-chain hygiene, documentation,
maintainer responsiveness, and default-safe ergonomics. A single
high finding caps at C. Two caps at D. A silent downgrade of a security
behaviour — as we will see in §3 — is an automatic F on that axis.
§3 · The at_hash regression
Of the three findings we rate high, this is the one that should embarrass
the most people. In auth0-spa-js v4.2 — released quietly on a Friday
in late February — the validation of the at_hash claim against the
access token was moved behind an opt-in flag. The changelog notes
"improved performance for token rotation." The diff removes a six-line
check.
// auth0-spa-js@4.1 (removed in 4.2)
if (idToken.at_hash) {
const expected = base64url(
sha256(accessToken).slice(0, 16)
);
if (expected !== idToken.at_hash) {
throw new TokenValidationError('at_hash mismatch');
}
}
We reproduced the practical impact: an attacker able to inject an access
token into a victim's SPA — via any of the usual XSS-adjacent primitives,
or a compromised CDN — can pair it with a legitimate ID token from a
concurrent sign-in and the library will accept the combination without
complaint. The access token need not even belong to the authenticated
subject. The check that prevents this is the one that was removed.
§4 · Refresh-token rotation, or the lack of it
passport-openidconnect has never rotated refresh tokens on its own. It
is a middleware; rotation is, defensibly, an application concern. But the
library's own documentation demonstrates refresh with a snippet that
stores the refresh token in a signed cookie and never rotates it. Every
real-world integration we audited — eleven of them, chosen from public
GitHub dependents by weekly-downloads — followed the snippet. None
rotated.
Rotation is in the OAuth 2.1 draft for a reason: a stolen refresh token
is a stolen session, forever, until the subject logs out. That "forever"
is measured in the months you will take to notice.
§5 · PKCE, mis-applied
PKCE is not optional in 2026. Two of the three libraries do the right
thing and enforce it on public clients. passport-openidconnect accepts
any code_challenge_method the provider responds with — including the
string "plain", which defeats the entire point of PKCE and is forbidden
by RFC 7636 §4.3 for anything that calls itself a security control.
§6 · What to do on Monday
- If you are on
auth0-spa-js≥ 4.2, setvalidateAtHash: truetoday.
Consider pinning to 4.1 until the default is restored. - If you use
passport-openidconnect, audit your refresh-token storage.
Rotate on every use. Revoke on logout. Log rotation failures. - Pin PKCE to
S256at the client level. Do not trust the provider to
negotiate it for you. - Subscribe to the re-audit feed for these packages. We will re-grade
when the defaults change — not when the PR lands.
§7 · Maintainer responses
We sent drafts to all three projects on apr 11, with 10-day response
windows. openid-client replied within 36 hours, accepted the
documentation findings, and shipped clarifying docs before publication.
passport-openidconnect acknowledged receipt and committed to a
rotation example in the README. auth0-spa-js did not respond.