Resposta curta. O SD-JWT (RFC 9901, novembro de 2025) substitui cada claim ocultável de um JWT assinado pelo hash de uma disclosure: um pequeno array JSON codificado em base64url com um salt aleatório, o nome da claim e o valor dela. O emissor assina os hashes; depois, o portador envia o JWT assinado e apenas as disclosures que escolher, unidos por ~. O verificador recalcula os hashes e confere se cada um aparece no payload assinado. Um JWT de vinculação de chave (key binding JWT, typ: kb+jwt), assinado por uma chave indicada na credencial, amarra a apresentação a um único verificador (aud), a uma única requisição (nonce) e exatamente a essas disclosures (sd_hash). O SD-JWT VC acrescenta a camada de credencial: typ: dc+sd-jwt, um tipo de credencial em vct, status e metadados de tipo. Ele ainda é um rascunho do IETF (Internet-Draft), mas é um dos dois formatos de credencial que toda Carteira Europeia de Identidade Digital (EUDI Wallet) deve aceitar, ao lado do mdoc da ISO/IEC 18013-5.
Disclosures e digests
A disclosure de uma propriedade de objeto é o array [salt, name, value]; a de um elemento de array é [salt, value] (RFC 9901 §4.2). Ela é codificada em base64url como JSON em UTF-8, e o digest é o SHA-256 em base64url dessa string codificada, e não do JSON (§4.2.3). Como o hash cobre exatamente os bytes que o emissor escolheu, nenhuma canonicalização de JSON é necessária: a mesma claim codificada com outros espaços em branco é outra disclosure, com outro digest. Os dois pontos, conferidos com o próprio exemplo da RFC:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
O emissor coloca os digests em um array _sd no payload, ordenados ou embaralhados para que a ordem não revele nada, e pode acrescentar digests fictícios, que não correspondem a nenhuma disclosure (§4.2.5), para que o número de claims ocultas não vaze. _sd_alg indica a função de hash; o SHA-256 é o padrão e tem implementação obrigatória. O salt torna os digests impossíveis de adivinhar: sem ele, um verificador poderia calcular o hash de ["birthdate", "1984-01-26"] para cada data plausível e encontrar a que corresponde. A RFC 9901 recomenda pelo menos 128 bits aleatórios por salt, escolhidos de forma independente para cada claim (§9.3).
Na transmissão, um SD-JWT tem a forma <issuer-signed JWT>~<disclosure>~…~<disclosure>~, e uma apresentação com vinculação de chave substitui a última parte, vazia, pelo JWT de vinculação de chave: …~<disclosure>~<KB-JWT> (§4). É pelo ~ final que o verificador distingue as duas formas.
Emissor, carteira e verificador em 76 linhas
O módulo abaixo implementa o caso plano, em que toda claim ocultável é uma propriedade de nível superior, o que basta para ver cada regra em ação. Disclosures aninhadas e recursivas seguem os mesmos passos nos objetos internos (§6).
import { createHash, randomBytes } from "node:crypto";
import { SignJWT, calculateJwkThumbprint, decodeJwt, importJWK, jwtVerify } from "jose";
const b64url = (text) => Buffer.from(text, "utf8").toString("base64url");
// RFC 9901 §4.2.3: SHA-256 over the ASCII of the base64url Disclosure, then base64url.
export const digest = (disclosure) => createHash("sha256").update(disclosure, "ascii").digest("base64url");
const salt = () => randomBytes(16).toString("base64url"); // 128 bits (§9.3)
// --- Issuer ------------------------------------------------------------------
/** claims: always-visible claims; hidden: claims to make selectively disclosable. */
export async function issue({ issuerKey, holderJwk, claims, hidden, decoys = 2 }) {
const disclosures = [];
const sd = [];
for (const [name, value] of Object.entries(hidden)) {
const d = b64url(JSON.stringify([salt(), name, value]));
disclosures.push(d);
sd.push(digest(d));
}
for (let i = 0; i < decoys; i++) sd.push(digest(salt())); // decoy digests (§4.2.5)
sd.sort(); // order must not reveal which claim is which
const jwt = await new SignJWT({ ...claims, _sd: sd, _sd_alg: "sha-256", cnf: { jwk: holderJwk } })
.setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt", kid: "issuer-2026-10" })
.sign(issuerKey);
return `${jwt}~${disclosures.join("~")}~`;
}
// --- Holder ------------------------------------------------------------------
export async function present({ sdJwt, reveal, holderKey, aud, nonce }) {
const [jwt, ...rest] = sdJwt.split("~");
const chosen = rest.filter(Boolean).filter((d) => reveal.includes(JSON.parse(Buffer.from(d, "base64url"))[1]));
const presented = `${[jwt, ...chosen].join("~")}~`;
const kb = await new SignJWT({ aud, nonce, sd_hash: digest(presented) }) // sd_hash: §4.3.1
.setProtectedHeader({ alg: "ES256", typ: "kb+jwt" })
.setIssuedAt()
.sign(holderKey);
return presented + kb;
}
// --- Verifier ----------------------------------------------------------------
export async function verify(presentation, { issuerJwks, issuer, vct, aud, nonce, maxKbAge = 60 }) {
const parts = presentation.split("~");
const kbJwt = parts.pop(); // "" when there is no Key Binding JWT
if (!kbJwt) throw new Error("key binding required, none presented"); // §7.3 step 2
const [jwt, ...disclosures] = parts;
// §7.1 step 2: issuer signature, typ, algorithm, issuer, hash algorithm.
const { payload } = await jwtVerify(jwt, issuerJwks, { algorithms: ["ES256"], typ: "dc+sd-jwt", issuer });
if (payload._sd_alg !== "sha-256") throw new Error(`unsupported _sd_alg ${payload._sd_alg}`);
if (payload.vct !== vct) throw new Error(`unexpected vct ${payload.vct}`);
// §7.1 steps 3-5 (top-level object properties only, which is all this issuer emits).
const byDigest = new Map(disclosures.map((d) => [digest(d), d]));
if (byDigest.size !== disclosures.length) throw new Error("duplicate disclosure");
if (new Set(payload._sd).size !== payload._sd.length) throw new Error("digest appears twice");
const { _sd, _sd_alg, ...claims } = payload;
for (const sdDigest of _sd) {
const d = byDigest.get(sdDigest);
if (!d) continue; // undisclosed claim or decoy
byDigest.delete(sdDigest);
const decoded = JSON.parse(Buffer.from(d, "base64url"));
if (!Array.isArray(decoded) || decoded.length !== 3) throw new Error("not an object-property disclosure");
const [, name, value] = decoded;
if (name === "_sd" || name === "..." || name in claims) throw new Error(`claim name ${name} not allowed`);
claims[name] = value;
}
if (byDigest.size > 0) throw new Error("disclosure not referenced by the issuer-signed JWT"); // step 5
// §7.3 step 5: Key Binding JWT signed by the key in cnf, fresh, for us, over exactly this presentation.
const holderKey = await importJWK(payload.cnf.jwk, "ES256");
const { payload: kb } = await jwtVerify(kbJwt, holderKey, { algorithms: ["ES256"], typ: "kb+jwt", audience: aud, maxTokenAge: maxKbAge });
if (kb.nonce !== nonce) throw new Error("key binding nonce mismatch");
if (kb.sd_hash !== digest(`${parts.join("~")}~`)) throw new Error("sd_hash does not match the presented SD-JWT");
return { claims, holderThumbprint: await calculateJwkThumbprint(payload.cnf.jwk) };
}
export const peek = (jwt) => decodeJwt(jwt);
As regras do verificador correspondem uma a uma às da RFC 9901 §7.1 e da §7.3: rejeitar none; verificar o emissor e a chave dele; reconhecer o _sd_alg; associar cada disclosure a exatamente um digest; rejeitar disclosures com o nome _sd ou ... ou que colidam com uma claim visível; rejeitar um digest que apareça duas vezes e uma disclosure à qual nada se refere; depois, verificar a assinatura do JWT de vinculação de chave com a chave em cnf, além do typ, da idade, do aud, do nonce e do sd_hash dele. Atenção ao passo 1 da §7.3: exigir ou não a vinculação de chave é uma política do verificador e nunca depende de o portador ter enviado, por acaso, um KB-JWT.
Execução e verificação cruzada
O script de teste emite uma credencial de identidade com cinco claims ocultáveis (nome, sobrenome, data de nascimento, endereço e limites de idade), apresenta apenas os limites de idade a uma loja, verifica a apresentação com o módulo acima e com o @sd-jwt/sd-jwt-vc 0.23.0, da OpenWallet Foundation, e depois tenta seis manipulações contra os dois:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
issued SD-JWT: 1438 chars, 5 disclosures, _sd has 7 digests (2 decoys)
presentation: 1385 chars, 1 disclosure
hand-written verifier: {"iss":"https://issuer.example","vct":"https://credentials.example/identity_credential","iat":1792499582,"exp":1824035582,"age_equal_or_over":{"18":true,"21":true,"65":false}}
@sd-jwt/sd-jwt-vc: age_equal_or_over {"18":true,"21":true,"65":false}, given_name undefined, kb verified
disclosure with a changed value | disclosure not referenced by the issuer-signed JWT | Unreferenced disclosure(s) detected in SD-JWT
same presentation, another verifier | unexpected "aud" claim value | Verify Error: Invalid Key Binding audience
same presentation, a new nonce | key binding nonce mismatch | Verify Error: Invalid Nonce
extra disclosure appended | sd_hash does not match the presented SD-JWT | Invalid sd_hash in Key Binding JWT
key binding JWT removed | key binding required, none presented | Key Binding JWT not exist
key binding by another key | signature verification failed | Verify Error: Invalid JWT Signature
Os dois verificadores concordam em todos os casos. O que as linhas mostram:
- A divulgação seletiva funciona. A loja recebeu
age_equal_or_over;given_nameéundefinedno resultado da biblioteca porque a disclosure dessa claim nunca foi enviada. Os dois verificadores reconstruíram as mesmas claims. - O portador não consegue editar uma claim. Alterar a disclosure da idade gerou um digest que o emissor nunca assinou.
- Uma apresentação vale para um verificador e uma requisição. Reenviada para outro público-alvo, ou com um nonce antigo, ela foi rejeitada.
- O
sd_hashcongela a seleção. Acrescentar uma disclosure depois que a carteira assinou o JWT de vinculação de chave invalidou a apresentação. - Sem a chave da carteira, a credencial não serve para nada, se o verificador exigir vinculação de chave: um JWT de vinculação de chave removido ou assinado de novo por outra chave foi rejeitado.
E a execução mostra algo que é mais uma lição de projeto do que uma verificação: a loja pediu “maior de 18” e ficou sabendo também “não é maior de 65”, porque o emissor colocou todos os limites em uma única disclosure. A granularidade é definida na emissão. A RFC 9901 §6.2 mostra a forma estruturada em um objeto address: cada propriedade recebe sua própria disclosure, e o objeto mantém um único array _sd, com um digest por propriedade. Emitida dessa forma, a claim age_equal_or_over permitiria que a carteira liberasse só "18": true.
O que a divulgação seletiva não oculta
A divulgação seletiva oculta valores, não a credencial. Toda apresentação de um mesmo SD-JWT leva a mesma assinatura do emissor, os mesmos digests em _sd e a mesma chave em cnf, então dois verificadores que comparem o que receberam conseguem perceber que viram a mesma credencial, mesmo que um tenha recebido só uma idade e o outro só um código postal. A RFC 9901 §10.1 é direta sobre isso: o formato “não atende às propriedades de segurança de credenciais anônimas”, e um emissor que recebe de volta as apresentações de um verificador sempre consegue reconhecer a própria credencial. O mesmo vale para o mdoc, que usa a mesma abordagem de hashes com salt.
A atenuação que a RFC descreve é a emissão em lote (batch issuance): o emissor entrega à carteira várias credenciais com as mesmas claims, e a carteira usa uma diferente para cada verificador ou até para cada sessão. Para isso funcionar, cada credencial do lote precisa de salts próprios e de uma chave de vinculação própria, e as claims de tempo, como iat e exp, devem ser aleatorizadas ou arredondadas, porque timestamps exatos permitem relacionar apresentações tão bem quanto um número de série. A emissão em lote impede que os verificadores relacionem apresentações entre si; ela não consegue esconder uma apresentação do próprio emissor. As consultas de status são um vazamento parecido: um verificador que pergunta ao emissor sobre uma credencial revela a ele onde ela é usada, e é por isso que a Token Status List permite que os verificadores baixem uma lista compactada que cobre muitas credenciais, em vez de perguntar sobre uma só.
O que o SD-JWT VC acrescenta
O SD-JWT é um contêiner. A especificação Credenciais verificáveis baseadas em SD-JWT define como usá-lo para credenciais:
typ: dc+sd-jwt, com o tipo de mídiaapplication/dc+sd-jwt. Até a versão -06, o rascunho usavavc+sd-jwt; o nome mudou para evitar ovcusado pelo modelo de dados de credenciais verificáveis do W3C (Verifiable Credentials Data Model). Verificadores que lidam com implementações antigas ainda podem encontrarvc+sd-jwt.vct, obrigatório: o tipo da credencial, uma string resistente a colisões. Os tipos podem ter metadados de tipo legíveis por máquina, para schemas e exibição.iss,cnf(a chave do portador, obrigatória quando se usa vinculação de chave),status(revogação por meio de uma Token Status List) e as claims de validade. Essas claims, junto comvct, não devem ser divulgáveis seletivamente: o verificador deve sempre ver quem emitiu a credencial, a quem ela está vinculada e se ainda é válida.
Situação em 20 de outubro de 2026: o SD-JWT VC está no rascunho -19 (31 de agosto de 2026), já submetido ao IESG, e ainda não é uma RFC. A Token Status List, que ele usa para revogação, foi aprovada e está na fila do RFC Editor. A própria RFC 9901 já foi publicada.
A Carteira Europeia de Identidade Digital
O Regulamento eIDAS revisado (Regulamento (UE) 2024/1183) exige que cada Estado-membro disponibilize pelo menos uma Carteira Europeia de Identidade Digital em até 24 meses após a entrada em vigor dos principais atos de execução do regulamento. Esses atos, entre eles o Regulamento de Execução (UE) 2024/2977, sobre os dados de identificação pessoal, foram publicados em 4 de dezembro de 2024 e entraram em vigor em 24 de dezembro de 2024, o que fixa o prazo em 24 de dezembro de 2026.
A parte técnica é descrita no Architecture and Reference Framework, cuja versão 3.0.0 foi lançada em 23 de julho de 2026 (o ARF em si é informativo; o texto vinculante são os atos de execução). O capítulo 5 diz:
- As carteiras aceitam dois formatos de credencial: o mdoc da ISO/IEC 18013-5 e o SD-JWT VC. O W3C VCDM 2.0 é opcional e vale apenas para atestados não qualificados.
- Credenciais mdoc são apresentadas por proximidade pela ISO/IEC 18013-5 e remotamente pela ISO/IEC 18013-7 ou pelo OpenID for Verifiable Presentations com o perfil HAIP. Credenciais SD-JWT VC são apresentadas apenas remotamente, pelo OpenID4VP com o HAIP; elas “não podem ser usadas em apresentações por proximidade”.
- Os dados de identificação pessoal (PID) usam o tipo de documento mdoc
eu.europa.ec.eudi.pid.1e o tipo SD-JWT VCurn:eudi:pid:1, com tipos nacionais, comourn:eudi:pid:de:1, no mesmo namespace (PID Rulebook).
Os protocolos OpenID em torno da carteira já são finais: OpenID4VP 1.0 (julho de 2025), OpenID4VCI 1.0 (setembro de 2025) e HAIP 1.0 (dezembro de 2025), que define perfis do SD-JWT VC e do mdoc para uso de alta garantia.
SD-JWT VC e mdoc lado a lado
O ARF chama os dois mecanismos de divulgação seletiva de “conceitualmente idênticos”: ambos assinam digests com salt de atributos individuais. A diferença está em tudo o que os cerca. A coluna do mdoc usa os nomes de estrutura da norma ISO, que não é de acesso livre; a biblioteca de código aberto @auth0/mdl expõe os mesmos nomes em seus tipos.
| SD-JWT VC | mdoc da ISO/IEC 18013-5 | |
|---|---|---|
| Codificação | JSON, JWS, base64url | CBOR, COSE |
| Atributo oculto | disclosure [salt, name, value] | IssuerSignedItem com random, digestID, elementIdentifier, elementValue |
| Onde ficam os digests | arrays _sd no JWT assinado pelo emissor | valueDigests por namespace no Mobile Security Object (MSO), assinado pelo emissor |
| Vinculação ao portador | JWT de vinculação de chave, opcional no formato | chave do dispositivo no MSO e autenticação do dispositivo, obrigatórias |
| Confiança no emissor | chave do emissor, por exemplo via x5c ou metadados do emissor | cadeia de certificados do emissor até uma autoridade emissora |
| Apresentação na carteira EUDI | só remota (OpenID4VP + HAIP) | por proximidade (NFC, BLE, QR) e remota |
| Origem | grupo de trabalho OAuth do IETF | norma da habilitação digital (mobile driving licence) |
Para um verificador, oferecer suporte à carteira significa aceitar os dois formatos, o que na prática exige uma biblioteca para cada um: análise de mdoc e verificação COSE não são coisas para implementar à mão.
Erros comuns
- Calcular o hash do JSON decodificado em vez da string base64url. Nenhum digest vai coincidir; o exemplo da RFC acima serve de teste.
- Salts curtos ou reutilizados. Eles permitem adivinhar os valores ocultos ou relacioná-los entre claims.
- Tratar a falta de um KB-JWT como “vinculação de chave dispensada”. Quem decide é a política do verificador, não a apresentação.
- Não verificar
audenonceno JWT de vinculação de chave. Assim, qualquer apresentação que um portador já tenha feito pode ser reenviada a você. - Disclosures pouco granulares. Uma disclosure por objeto revela o objeto inteiro; projete a estrutura para as perguntas que os verificadores vão fazer.
- Ignorar o
status. Uma credencial pode estar assinada, dentro da validade e revogada.
Fontes
- RFC 9901: Selective Disclosure for JSON Web Tokens, §4, §6, §7, §9
- draft-ietf-oauth-sd-jwt-vc, credenciais verificáveis baseadas em SD-JWT, versão -19
- draft-ietf-oauth-status-list, Token Status List
- Regulamento (UE) 2024/1183 e Regulamento de Execução (UE) 2024/2977
- Architecture and Reference Framework da EUDI Wallet, v3.0.0, capítulo 5; PID Rulebook
- OpenID4VP 1.0, OpenID4VCI 1.0, HAIP 1.0
- @sd-jwt/sd-jwt-vc e @auth0/mdl