A JWS carries two header sets. One is covered by the signature. The other is not. Reading the algorithm from the wrong one turns a verifier into something an attacker configures.
The line
JWSVerifier::getAlgorithm() merged both headers before reading alg:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
In PHP, spreading arrays with duplicate string keys gives precedence to the last one. The unprotected header comes second. So an alg placed in the header that nobody signed silently overrode the one that was signed.
Why that is serious
The unprotected header travels in clear and is not covered by the signature. Anyone relaying the token can rewrite it, and the signature still checks out, because the signature never covered that part.
An attacker can therefore choose the algorithm the verifier will use, which is the whole premise of algorithm-confusion attacks: present an RSA public key as an HMAC secret, and a token you forged with that public value verifies.
The fix
alg is now read from the integrity-protected header only. That is what the specification intends: a parameter that decides how a signature is checked has no business living outside what the signature covers.
Fixed in 3.4.10, 4.0.7 and 4.1.7, published on 6 June 2026.
What to take from it
Two things, and neither is specific to this library.
- Decide the algorithm on your side. Configure the one algorithm you accept and refuse the rest. Then the header cannot influence anything, whichever header it came from.
- Prefer the compact serialisation. It has no unprotected header at all. A whole class of problem disappears with it.
And the general lesson: merging two trust levels into one array is a decision, even when it looks like a convenience.