qredential

SD-JWT · Token Status List · base45

The answer is already inside the code

A credential that proves itself. Signature, expiry, revocation and claims, all checked without one request leaving the device.

TypeScript, zero runtime dependencies, WebCrypto only. Node, browsers and React Native, because the thing doing the verifying is usually a phone.

signing a demo credential…
 
 
network requests· verified in·

What this actually does

A credential is a statement somebody signed. “This person is over 18.” “This person may drive a car.” The signature is what makes it worth anything: it says an authority stands behind the statement, and that nobody edited it afterwards.

On paper, and in a PDF, one signature covers the whole document. To prove a single line of it you hand over all of it. Show a driving licence to prove your age and the other person also learns your address, your licence number and your exact date of birth. They never asked for any of that. They have it now anyway.

Selective disclosure breaks that trade. The issuer signs each fact separately, and the credential carries only a fingerprint of each one. You choose which facts to reveal, and the rest stay fingerprints that say nothing about their contents. The signature still checks out, because it was never a signature over one indivisible blob.

All of it fits inside the code the verifier scans: the signature, the fingerprints, and the facts you chose to share. Nothing is fetched while verifying, so a door, a bus, a rural clinic or an aeroplane at cruising altitude can check a credential with no network at all.

There is one last piece, and it is the one people forget. Anything you can scan, you can photograph. So the person presenting also has to sign a fresh challenge with a private key that never leaves their device. Without that step, a screenshot of somebody else's credential would pass. This library refuses any presentation that lacks it.

Built for the moment there is no signal

I built the eCNH, Brazil's digital driving licence, used by more than 40 million people. The part that taught me the most was not the app. It was the roadside: an officer scanning a licence on a highway with one bar of signal, or none, needing a yes or no in under a second.

Everything you need for that answer fits in the code. The signature proves the issuer, the claims are right there, and the only thing you need from outside is the issuer's public key, which changes so rarely that you can ship it and refresh it weekly.

Libraries for this exist. They are enterprise SDKs: heavy, tied to one country's profile, and written as though you already work in the identity industry. This is the version a product engineer can add on a Tuesday.

Three parties, and none of them is a server

01 · ISSUE

Sign every claim once

The issuer decides which claims may later be withheld, and signs a digest for each of those.

02 · PRESENT

Send only what is asked

The wallet drops the claims it is keeping. The issuer's signature still checks out on what is left.

03 · VERIFY

Answer without asking anyone

Signature, expiry, revocation and every disclosure, checked against a pinned key on the device.

Prove you are over 18 without handing over your birthday

Age verification laws are arriving faster than the tooling. The usual implementation has the customer upload a photo of their ID to a third party, which is a privacy disaster and a breach waiting to happen.

Selective disclosure does it properly. The verifier cannot learn the birth date even if it wants to, because that value never left the wallet. The property is cryptographic, not a promise in a privacy policy.

// The credential holds name, address, birth date and document number.
// The bar gets one boolean.
const presentation = await present(credential, { disclose: ['over_18'] })

const result = await verify(presentation, { trust })
result.claims             // { over_18: true }
result.claims.birth_date  // undefined, and it was never transmitted
result.withheld           // 4, and it cannot tell you which four

Real numbers, including the awkward one

A realistic driving licence, eight claims, five year expiry, status list pointer. Measured, not estimated:

CredentialCharactersQR version
everything visible~74018, scans fine
all eight claims disclosable~159027, too dense
presenting only over_18~111522, still dense
Selective disclosure roughly doubles the credential. Every disclosable claim costs a salt plus a signed digest, and the digests stay in the payload whether the holder reveals the claim or not. That is deliberate, since a digest count that shrank with what you revealed would leak what you withheld. It does mean the saving at presentation time is thirty percent here, not the eighty that intuition promises. Make two or three claims disclosable, not all of them, and let fits() tell you where you stand before you print anything.

Don't take my word for it

The playground runs the whole library in your browser and fires eight real attacks at it. Each one prints the rejection code it expects, so you can check the library against its own claims instead of trusting a README.

Flip a claim in the payload
bad_signature
Invent a claim never issued
digest_mismatch
Send a disclosure twice
digest_mismatch
Sign with the wrong key
bad_signature
Claim to be another authority
unknown_issuer
Use it ten years from now
expired
Use it after revocation
revoked
Hide behind a stale status list
status_list_stale
Replay a recorded presentation
holder_proof_invalid
Present a photo of someone's code
holder_proof_missing

Why this one, and when not

Five reasons to pick it, then the honest part.

  • About 1,300 lines you can actually readSmall enough that one engineer reads all of it in an afternoon and knows what it does. Security you cannot read is security you are taking on faith.
  • Zero dependenciesNothing from npm at runtime. There is no supply chain to audit but this one, and nothing that can change under you next week.
  • It refuses what it cannot fully checkNo partial passes, no warning you are free to ignore. Every failure comes back as a named reason you can log and act on, never as a bare false.
  • It runs where verification happensNode, Deno, Bun, browsers, React Native and edge workers. The same code everywhere, on the standard crypto already built into the platform.
  • Tested three waysUnit tests, the official test vectors published with RFC 9901, and property tests that throw random malformed input at it looking for a case that slips through.

And when this is the wrong tool.

  • You need ISO 18013-5 mobile driving licencesThat is a different encoding, CBOR and COSE, and a different standard. This library speaks SD-JWT, the format the European wallets and the OpenID specifications use. Related problem, different job.
  • You want a whole walletThis verifies and presents. It does not store credentials, manage devices or handle onboarding. It is one piece, meant to sit inside your product.
  • You need a third-party audit before a regulated rolloutThere is not one yet. The tests and the RFC vectors are public and so is every line, but for a regulated deployment you should budget for your own review.

Install

npm i qredential

Node 20 or newer, every current browser, React Native. About 1,300 lines of source with no runtime dependencies, specifically so that reading it before you trust it is realistic.

Standards, not inventions

  • SD-JWT, RFC 9901selective disclosure and key binding, nested and recursive, the mechanism the European identity wallet uses
  • SD-JWT VCthe credential shape
  • Token Status Listrevocation that works from a cached copy
  • base45, RFC 9285the QR envelope, chosen for scanner compatibility

Not ISO 18013-5 mDL, which is CBOR and COSE rather than JWT. It is on the roadmap, and a credential using something unsupported is refused rather than half understood. Claiming half of a compliance standard is worse than not claiming it.