Summary
SD JWT JS: Tampered disclosures silently dropped instead of rejected
When one Disclosure in an SD-JWT presentation is tampered so its digest no longer matches the Issuer-signed _sd array, @sd-jwt/decode 0.19.0 does not reject the presentation. It silently drops the unmatched Disclosure and returns a reduced claim set, reporting success. The Issuer signature is untouched and valid.
This is a downgrade/omission primitive. A malicious or curious holder can present a tampered Disclosure and have the relying party receive fewer claims than the credential carries, with no error surfaced. Where the relying party does not enforce key binding, an in-transit (on-path) attacker can achieve the same by mangling one Disclosure's bytes; where key binding is enforced, on-path tampering instead breaks the holder's KB-JWT sd_hash binding (so it is rejected), and the downgrade is then exercisable by the key-bound holder, confirmed end-to-end with KB required.
Two distinct tampering inputs converge on the same silent-drop sink:
- Genuinely non-decodable Disclosure (broken base64url -> bytes that are not valid UTF-8, e.g. an injected
0xF2lead byte). RFC 9901 §7.1 step 3 / §4.2.1 require rejection.@sd-jwt/decodeinstead lossy-decodes it: theTextDecoder/Bufferpath substitutes U+FFFD (REPLACEMENT CHARACTER) for the invalid byte and recovers a syntactically valid disclosure (thesaltgains a U+FFFD, claimfamily_name="Smith"). The recovered Disclosure then hashes to a digest absent from the signed_sdarray, so it is silently skipped. - Tampered-but-still-valid-JSON Disclosure (digest no longer matches
_sd): same end state, the unmatched Disclosure is silently skipped.
Two RFC 9901 MUSTs compound:
- §7.1 step 5 (primary): "If any Disclosure was not referenced by digest value in the Issuer-signed JWT (directly or recursively via other Disclosures), the SD-JWT MUST be rejected.", instead silently dropped.
- §7.1 step 3 / §4.2.1 (secondary): a non-base64url-JSON Disclosure MUST be rejected, the lenient lossy decode skips this check.
Confirmed end-to-end through SDJwtInstance.verify() with a real ES256 issuer key, and again with key binding REQUIRED (a valid holder ES256 KB-JWT over aud+nonce+sd_hash).
Proof of concept (minimized, NOT a weaponized exploit)
A clean single-mutation credential: lowercase _sd_alg: "sha-256", an honest Issuer body whose _sd digests are computed over the valid wire Disclosures, and exactly one wire byte-segment tampered, the family_name Disclosure made genuinely non-decodable (an invalid-UTF-8 0xF2 byte injected into its base64url). Everything else is byte-for-byte valid.
The byte-exact credential cannot be faithfully pasted as text (it deliberately contains the invalid 0xF2 byte that triggers the lossy decode). It is provided as bug3_clean_tampered.cred with an untampered twin bug3_clean_control.cred, plus the minting script mint_bug3_clean.py and a three-oracle harness run_three_oracles.py, shared privately with the maintainer on first reply. Structure: 5 Disclosures, only index 1 (family_name) tampered; all other segments byte-for-byte valid.
Observed on the byte-identical credential (three independent oracles):
| Oracle | Version | Verdict | Detail |
|---|---|---|---|
| RFC 9901 reference (pure Python) | reference impl | REJECT | R_DISCLOSURE_DECODE |
sd-jwt (Daniel Fett's reference impl) |
pip 0.10.4 | REJECT | UnicodeDecodeError: 0xf2 ... invalid continuation byte |
@sd-jwt/decode (this package) |
0.19.0 | ACCEPT | reduced set {given_name, email, vct, iss}, family_name silently dropped, no error |
Control (untampered, same body): all three oracles ACCEPT the full 5-claim set including family_name: "Smith", confirming the single tampered byte is the only difference. Liveness: the @sd-jwt/decode harness REJECTs pure garbage ("not-a-jwt", "~~~~", "aaa.bbb.ccc~ZZZZ~", "") and ACCEPTs the clean control, so the ACCEPT above is a genuine behavioral divergence, not an accept-everything stub. No ready-to-run exploit against any deployed verifier is included.
Related findings in the same library (MEDIUM, context for one combined patch)
- Reserved claim name
_sdaccepted (§7.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (_sd,_sd_alg, ...). _sd_algnot validated case-sensitively / against the registry (§4.1.1, §7.1 step 2). MEDIUM (defense-in-depth). Acceptssha-1andSHA-256(wrong case, silently normalized tosha-256). Fix: exact, case-sensitive membership test against the supported registered set; no case normalization, no silent default.
The maintainer may prefer to address all three in one advisory/patch.
Credit
Found and reported by Anton Sokolov, Tyche Institute (Tallinn, Estonia), ORCID 0000-0003-2452-7096. Discovered via differential fuzzing of the SD-JWT disclosure layer against an RFC 9901 reference oracle and two reference libraries.
Impact
Silent claim-set reduction is a downgrade/omission primitive. If a relying party's authorization logic is "default-allow when a constraining claim is absent" (an age / role / restriction attribute), dropping that one Disclosure flips the decision, with no error surfaced. The decoder reporting success on a tampered presentation is the core problem; tampering should be a hard reject. Because SD-JWT VC and the EU EUDIW ARF profiles reuse this disclosure layer, the exposure is to wallet/verifier deployments built on @sd-jwt/*.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
In the disclosure-decode loop of @sd-jwt/decode:
- Enforce §7.1 step 5: if any provided Disclosure is not referenced by a digest in the signed
_sdarray (directly or recursively), reject the entire presentation, do not silently skip it. - Enforce §7.1 step 3 / §4.2.1: treat a Disclosure that fails base64url/JSON decode, or that is not a 3-element
[salt, claimName, claimValue]array, as a fatal error. Replace the lenientTextDecoder/Bufferdecode with a strict UTF-8 decode that throws on invalid bytes (do not substitute U+FFFD). - Surface both rejections from the public
verify()/decodeSdJwt()entry points, not only from an internal helper.
Frequently Asked Questions
- What is CVE-2026-62374? CVE-2026-62374 is a high-severity security vulnerability in @sd-jwt/decode (npm), affecting versions <= 0.19.0. It is fixed in 0.20.0.
- Which packages are affected by CVE-2026-62374?
@sd-jwt/decode(npm) (versions <= 0.19.0)@sd-jwt/core(npm) (versions <= 0.19.0)
- Is there a fix for CVE-2026-62374? Yes. CVE-2026-62374 is fixed in 0.20.0. Upgrade to this version or later.
- Is CVE-2026-62374 exploitable, and should I be worried? Whether CVE-2026-62374 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-62374 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-62374? Upgrade
@sd-jwt/coreto 0.20.0 or later.