Verifying your age in a privacy preserving manner

Posted on 27 Apr 2026
Tagged with: [ sd-jwt ]  [ privacy

There is a lot going on in the world regarding age verification. Why does every website or operating system (and possibly later: smart appliances like your fridge??) need to know your age?

While I don’t agree with a generic “think of the children” excuse, I do understand that there are certain sites you want to restrict for younger ages, like adult and gambling sites. But are we really willing to scan our ID cards and post them to those sites? Or, possibly worse, have a provider with a centralized database containing all these ID cards? Privacy AND security hell.

So, let’s not talk about the political or legal side of things. I neither care about nor understand them anyway. Let’s focus on the technical side instead.

The question a website wants answered is: “Is this person old enough to access my site?” Note that it does not ask for a birthdate, a name, a social security number or anything else. It just wants a boolean true or false, and it wants to be sure the answer is correct. So how can we do this without sharing any personal information?

Meet the three players

Let’s assume we have three players in this game. The first is the user, or holder which is you. The second is the issuer, which could be the government, a bank, or any “official” instance from which we can trust the information. In theory, this could also be your local bowling alley, where websites can verify your high score there. But mostly we’ll be talking about governments, banks, insurance companies, and so on.

The last player is the website, or verifier, which is the one asking the question: “is this person old enough to access my website?”.

The verifier asks the question to the user/holder, and the user responds with true or false. But since it’s the user themselves answering the question, we need to make sure they cannot lie. So we need some kind of proof that the user really is old enough.

JWT

Let’s talk about everyone’s favourite authentication format: JSON Web Tokens (JWT).

The idea is that the issuer can issue a token to the user, who stores it on their phone or computer in some kind of software “wallet” system. This token contains “claims” about the user.

For instance:

  • This user’s first name is “John”
  • This user’s last name is “Doe”
  • This user is born on 1990-01-01
  • This user is older than 18 years
  • This user is older than 21 years
  • This user is NOT older than 65 years
  • This user lives in the Netherlands
  • This user is an EU citizen

These are claims your government can attest to. So what we can do is create a JWT with this information, sign it with a government private key, and send it to the user. Then, when a website asks the user “are you older than 18?”, the user can send the JWT to the verifier. The verifier can see that the user is over 18 (which is one of the claims), and it can verify that the JWT is properly signed by the government, so it can trust that this information has not been tampered with.

But there is a problem: we have to send over the WHOLE JWT in order to check its signature, and the JWT contains all kinds of claims that the verifier does not need (and that we don’t want to share). So how can we solve this problem?

SD-JWT

The solution is called the Selective Disclosure JWT (SD-JWT). It’s still a regular JWT, but instead of having the claims out in the open, the JWT only contains hashes of them. So the JWT looks something like this:

{
  "iss": "https://issuer.example.com",
  "iat": 1777284545,
  "exp": 1808820545,
  "vct": "https://example.com/credentials/identity",
  "_sd_alg": "sha-256",
  "_sd": [
    "Df2U9BdIq2EhjP3ug5WFWwJj4m7YbFwmXN3PUeei1ww",
    "6ftrGs5psGjvfdgprm3hBtboZboY01EIa_ub2j4Ihdk",
    "8c8PYhi64urebwOLFb1wqPXTGuiK7cjNo66DD25V0rg",
    "yHNhQ4r3CU5UnffbkSx_joWknEDHWLu75subhL05hzs",
    "5QRyJ-JSZquCn_PFMipn1Ww5f-9SwFLaIBA0ZZhu_No",
    "mCgdcJrbdB6g4RFeO2l1yZHROe_M2Gq_WGylZsRaVOY",
    "Gz7i4rAowNCtKbxHOA-s82vMz0IqzeGuWNAh4o09ETU",
    "JiVANmga3_0-nF0a8-hhiGNQglGFXnJy0ctyl1iFpfs"
  ]
}

Each of these hashes is the hash of a “disclosure”, which is a JSON array containing the claim name, the claim value and a random salt. These disclosures are not stored in the JWT, but they are stored separately by the user. The issuer also sends the disclosures themselves to the user.

In this case, the disclosures are:

["lUwlCNphIRj9q4IR_BJEhA","first_name","John"]
["Fw-s4zIxTG_on-gozoXeog","last_name","Doe"]
["MuRVM4nDhy44EsmMDa1aUQ","birthdate","1990-01-01"]
["rZEHHTVlchLBWEOX4jYZXg","age_over_18",true]
["9Y833-G9mjCz39Wmd2JdgQ","age_over_21",true]
["whcFrFgOsmZKSF72ogms8A","age_over_65",false]
["zGb1YLUe0sob6Yux70tncQ","country","NL"]
["wcsi2sobo9dH6glPfgaQyA","eu_citizen",true]

Now we are at the point where the verifier asks the user “are you older than 18?”. Assuming the verifier knows to ask for the claim age_over_18, it sends that question to the user: can you provide your credential JWT, and the information for the claim age_over_18?

You then send the JWT and the disclosure for age_over_18 to the verifier. The verifier can now reconstruct the hash for this claim: it knows the salt, the claim name and the claim value, so it computes the hash itself and checks whether it matches one of the hashes in the JWT.

disclosure = base64url( JSON.stringify(["rZEHHTVlchLBWEOX4jYZXg", "age_over_18", true]) )
hash = sha256(disclosure)

Since this matches, we know:

  • the claim age_over_18 is true and the user has not lied about it (since the hash matches the one in the JWT)
  • the claim is attested by the issuer (since the JWT is properly signed)

And we don’t know anything about the other claims, since we did not disclose the salt or the value for them. It is not possible for the verifier to do any matching of any kind.

Drawbacks

There are a few drawbacks to this approach.

SD-JWT provides selective disclosure, but not strong unlinkability. Reusing the same credential across sites can allow correlation, unless additional measures (like per-verifier credentials or holder binding) are used.

If we use the same credential but disclose different claims to different sites, those sites could in theory combine the claims they each saw and build up a profile of the user. Normally a website could (or should) only ask for one or two claims, but think about larger companies like Google or Facebook. They run so many different sites, requiring so many different claims, that they could easily combine what they collect into a pretty detailed picture of a user. But, obviously, those companies would never do that, right? Right!??

Another issue with SD-JWTs: what if I want to know whether a user is over 25 (which can happen for car rentals, for instance)? In that case, asking for the birthdate is the only option. Basically, we cannot answer anything that hasn’t already been baked into the credential by the issuer. We can combine information “are you a 21-year-old EU citizen?” but we cannot ask whether your last name starts with a “D” without exposing the whole last name.

There are different ways to solve this, and they have to do with zero-knowledge proofs (how can we prove that we know that 3 times 3 is 9, without revealing that it’s 9?). But that’s a topic for another post.