Proving Consent on Ethereum: DIDs, JWTs, & Smart Contracts
The Veramo team has been hard at work building the foundations for decentralised identity on Ethereum. Through their
ethr-did-registry contract, users are now able to manage their identity on-chain and even make off-chain claims which can be easily verified by another Ethereum account. This means we can now efficiently prove consent between any Ethereum account privately without the need of a trusted third party!
This user-centric model is a significant shift towards realising a viable decentralised identity layer where users are ultimately in control of their own digital data. For more context and a brief on why decentralised identity is so important, you can refer to my short series:
- Reclaiming Our Identities: Why Decentralised Identity Matters
- Decentralised Identity: We Are More Than Our Finances
- Decentralised Identity: The Way Forward
Having covered the concepts, this article is all about the technical implementation of DIDs on the Ethereum (Goerli testnet) chain using JSON Web Tokens and Smart Contracts. Aside from decentralised identity specific concepts, it is assumed that you have some basic familiarity with Express.js, Ethers.js, and Metamask. If you would like a refresher of how these 3 are integrated, especially around running Ethers.js in the browser, you can refer to:
Connect Metamask with Ethers.js
The Github repo for this guide can be found here:
Key Concepts & Development Notes
Before diving into the code, there are a few key data objects that we will be working with:
- DID: A globally unique decentralised identifier with features designed for blockchains. Consists of a DID Method which defines how DIDs work within a specific blockchain as well as a Method-Specific Identifier which is unique within the method’s namespace.
- DID Document: A JSON-LD object that describes the public keys and service endpoints necessary to bootstrap cryptographically-verifiable interactions with the subject in question. For more info on DIDs and DID Documents - DID: Decentralised Identity’s Starting Line
- Verifiable Data Registry: A system that facilitates the creation, verification, updating, and/or deactivation of DIDs and DID Documents. In this case, we are using the
- Key Pair: A pair of public and private keys which represents an Ethereum account that enables public resolving of an address as well as private control over account actions. For more info: Ethereum Book
- JSON Web Token (JWT): Defines a compact and self-contained way for securely transmitting information between parties as a JSON object. Refer JWT website: https://jwt.io/introduction
- Private Claim (JWT Specific): A customisable claim which have been pre-agreed between the producer and consumer of the JWT claim. We will be using this as an example of data which can be included in a JWT payload. You can refer to the JWT RFC7519 spec here.
This is great resource if you would like to dive deeper into these concepts:
You Are Here (or should be): https://identity.foundation/faq This is the Decentralized Identity Foundation's…
For more specific DID properties, you can refer directly to the DID spec:
Decentralized Identifiers (DIDs) v1.0
Decentralized identifiers (DIDs) are a new type of identifier that enables verifiable, decentralized digital identity…
This guide comprises of 3 logically separate stages when issuing/verifying an Ethereum DID-JWT:
- Issuance of a JWT with a private claim that is signed by the Issuer
- Adding of JWT to Subject DID Document by the Subject [OPTIONAL: Only for persisting claims on-chain, to consider privacy requirements]
- Validation of Subject DID Document and JWT payload by the Audience
Each of the above stages are separated into their own separate page (Issuer/Subject/Audience App) which requires you to interact with the JWT via separate role accounts on Metamask.
The intention behind manually operating each stage is to provide a more detailed view into sections of the end-to-end flow. Do note that although the sequence diagrams indicate saving the JWT to a “Subject Device” (i.e. identity wallet), I have opted to temporary store it on the session token for simplicity.
Signing and Issuance of JWTs
We first need to prepare the claims that will be in the JWT. This consists of the private claim as well as the parties to the claim:
- “iss”: The “iss” (issuer) claim identifies the principal that issued the
JWT. This account will require some test ETH in order to sign the JWT.
- “sub”: The “sub” (subject) claim identifies the principal that is the
subject of the JWT. This account will require some test ETH in order to update the on-chain DID Document.
- “aud”: The “aud” (audience) claim identifies the recipients that the JWT is intended for.
All Ethereum addresses will have to be formatted in their DID method equivalent. As we are using the
did:ethr: method, our equivalent DID will be in the following format:
did:ethr:<chainId>:<ethAddress>. Do note that the
chainId is being pulled based on the connected Metamask network. The
ethr-did library provides a helpful wrapper around the connected account that enables us to conveniently interact with DIDs.
You can easily overwrite the defaults for these 3 fields through the UI or changing the relevant variables in the code (
privateClaim). In particular, we will need access to the subject private keys in order to sign on-chain transactions in the following optional section. On building the JWT message, you should see the object printed to the browser’s console:
With the message configured, we can go ahead and sign the JWT message with the Issuer connected Metamask account.
This will trigger a request from Metamask for you to confirm the transaction. Note that you might have to change the suggested gas fee in Metamask for the transaction to be mined.
If you opened the data tab of the transaction, you would have noticed that we are creating a delegate signer based on the Issuer Metamask wallet. This is required as “web3 providers are not directly able to sign data in a way that is compliant with the JWT ES256K or the (unregistered)
ES256K-R algorithms” (Getting Started Ethr-Did). By creating a delegate signer, a new
verificationMethod object linking the delegate issuer will be added to the Issuer’s DID Document. This is what enables the JWT to be verified later.
Note that the
ethr-did library will also replace the
iss in our payload with the DID of the delegate signer (updated guide signing with Issuer DID here). Once confirmed by the Goerli testnet, you should see the delegate address as well as JWT being displayed in the UI:
The signed JWT is a Base64 concatenation of the signing input (header and payload) as well as resulting signature, each separated by a
.. Critically, given that the JWT is self-contained, this means that the JWT inputs are able to be decoded based on the ownership of the JWT alone. As such, any JWT that contains personally identifiable or sensitive information should never be persisted on-chain as it will be permanently viewable to all. In this case, it is recommended that the JWT be stored on the Subject’s device (i.e. identity wallet) from where it can be efficiently retrieved based on user consent. JWTs should be reissued where required (i.e. loss of device/DID).
Having said that, the next section explores the feasibility of storing public attributes on-chain via logs. Do note that a DID Document is built at the point of the resolve request by using only read functions as well as contract events. One such event is
DIDAttributeChanged which is what we will be using to add non-Ethereum attributes to the DID document.
Adding JWT to Subject DID Document [OPTIONAL]
It bears repeating that this section diverges from the recommended design pattern (see section above) but is included for the purposes of exploring publicly persisted claims. The JWT can be verified without requiring on-chain storage as the Issuer’s DID Document already contains the link to the delegate signer which can be verified against the decoded JWT (
verificationMethod[i].blockchainAccountId). With privacy considerations in place, some possible use cases for this flow will revolve around public and permanent public domain data such as certifications and verified achievements.
Navigate to the Subject App and change the connected Metamask account to that of the Subject. You can check the connected account by clicking the following button:
With the Subject account connected and funded with some test ETH, we can then add the JWT to the Subject DID Document by selecting the “Add JWT” button which will prompt you to sign the transaction in Metamask. A transaction receipt is returned once the JWT has been added:
You can also view the confirmed transaction and the resulting
DIDAttributeChanged event via Etherscan:
Moreover, by resolving the Subject’s DID Document after the attribute change, we can also see that the JWT was added to the
Note that the JWT is stored as a DataHexString which can easily be converted back into the original UTF8 format as detailed in the next section.
Validating the JWT
While the JWT can be easily verified by calling
verifyJWT() from the
ethr-did library, this guide also implements a validation flow using the alternate flow above. Aside from differences in terms of whose DID Document is being pulled, the validation against the subject DID Document also aims to provide a highly simplified view as to how the JWT is verified:
- Decode the JWT
- Obtain the verification public key and controller from the decoded JWT (under
- Check JWT verification purpose
- Resolve the controller DID Document (and Issuer, if needed)
- Check Issuer DID Document
assertionMethodagainst Decoded JWT
- Check JWT expiry and valid
Validating Against Subject DID Document
By first resolving the Subject DID Document, we are able to access the
verificationMethod object which contains the
publicKeyHex where the JWT is stored. By comparing the UTF8 equivalent of the
publicKeyHex against the JWT, we can confirm that the Subject was issued the JWT and decided to add it to their DID Document.
As an additional validation step, we can also compare the decoded JWT payload (which contains “iss”, “sub”, “aud”) against the relevant addresses. Note that we were also able to extract the private claim from the public key hex using
Validate via Ethr-DID Convenience Library
ethr-did library verifies the JWT against the
iss DID Document which was updated in the first stage (i.e.
issuerDelegateKp.signJWT()), this flow can be achieved by the validator with just the JWT alone. In other words, the JWT can be stored privately off-chain and provided only when a Subject requests for validation by the Audience listed in the JWT.
For this flow, ensure that you are connected to the audience wallet in Metamask or you can configure the audience address via the form:
ethr-did will compare the configured audience DID against the JWT payload “aud” as part of the verification checks. To trigger the checks, you can then click on the “Validate JWT” button:
The verification will also return the result as well as the fully decoded payload which is then displayed in the UI. The full result can be viewed in the console:
Looking through the result, you can see that the
iss in the payload was actually replaced with the id of the delegate signer. Note that since the
verifyJWT() method verifies the included
iss, a success call here means that it has been verified that the JWT was issued by the delegate signer and not the Issuer. To link the delegate signer with the Issuer, we would need to sign the JWT with an updated Issuer DID object, which is covered here.
verified value is all that is required to validate the JWT, the
ethr-did library also returns additional details around the JWT and signer for convenience.