All security reports
OSV security advisoryHigh

High advisory for @sd-jwt/decode

GHSA-f9j6-8p6x-r9j6 · Reported 2 Nov 2026

Full advisory details, affected packages, severity context, and original source.

Affected technology

@sd-jwt/decodeAffected product

Advisory overview

## Summary 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: 1. **Genuinely non-decodable Disclosure** (broken base64url -> bytes that are not valid UTF-8, e.g. an injected `0xF2` lead byte). RFC 9901 §7.1 step 3 / §4.2.1 require rejection. `@sd-jwt/decode` instead **lossy-decodes** it: the `TextDecoder`/`Buffer` path substitutes U+FFFD (REPLACEMENT CHARACTER) for the invalid byte and recovers a syntactically valid disclosure (the `salt` gains a U+FFFD, claim `family_name` = `"Smith"`). The recovered Disclosure then hashes to a digest **absent** from the signed `_sd` array, so it is silently skipped. 2. **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`). ## 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/*`. ## 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. ## Suggested fix In the disclosure-decode loop of `@sd-jwt/decode`: 1. **Enforce §7.1 step 5:** if any provided Disclosure is not referenced by a digest in the signed `_sd` array (directly or recursively), reject the entire presentation — do not silently skip it. 2. **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 lenient `TextDecoder`/`Buffer` decode with a strict UTF-8 decode that throws on invalid bytes (do not substitute U+FFFD). 3. Surface both rejections from the public `verify()` / `decodeSdJwt()` entry points, not only from an internal helper. ## Related findings in the same library (MEDIUM — context for one combined patch) - **Reserved claim name `_sd` accepted** (§7.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (`_sd`, `_sd_alg`, ...). - **`_sd_alg` not validated case-sensitively / against the registry** (§4.1.1, §7.1 step 2). MEDIUM (defense-in-depth). Accepts `sha-1` and `SHA-256` (wrong case, silently normalized to `sha-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.

Affected packages and releases

npm / @sd-jwt/decode

introduced: 0 · last_affected: 0.19.0

npm / @sd-jwt/core

introduced: 0 · fixed: 0.20.0

Also known as

CVE-2026-62374

Related references

Your vulnerabilities. One clear dashboard.

Find and review known vulnerabilities across your applications and infrastructure in one dashboard, so your team can see what needs attention and take action to fix it.

Create your account