Week 5: Actually Issuing the Credential
Firstly before starting, apologies to everyone who might be following with me, I was busy with my internship interviews, (I had an interview with Google, I would talk about that in some other blog in detail).
So, I was unable to do much of the work, but, now I am back on track with full energy and motivation !
The first four weeks were about building the foundation , the schema, the DID, the GPG verification, the identity binding. Week 5 is where the foundation stops being theoretical. This is the week a real signed credential actually gets issued into a contributor's wallet.
Here's everything that happened.
The Problem This Week Solves
At the end of Week 4, we had a ContributorBinding record in the database that said: "this person's GitHub account, GPG key, and Heka wallet are all confirmed as belonging to the same human."
That's meaningful, but it's still just a database row. It lives entirely inside Heka's infrastructure. A wallet can't hold it. A verifier can't check it independently without calling Heka's API. It's not portable, and it's not self-contained.
What we actually want is a Verifiable Credential — a cryptographically signed document the contributor holds in their own wallet, which any verifier can check without calling home.
Week 5 is how we get there.
What is OID4VCI?
OpenID for Verifiable Credential Issuance (OID4VCI) is the protocol that defines how an issuer hands a credential to a wallet. It's the handshake between Heka (the issuer) and the contributor's wallet (the holder).
The short version of how it works:
- The wallet discovers what credentials an issuer can issue, via a
.well-known/openid-credential-issuermetadata document - The issuer creates a credential offer — a URI starting with
openid-credential-offer://that the wallet scans or follows - The wallet authenticates and presents proof of key possession
- The issuer signs the credential and delivers it
This week implemented steps 1 and 2. Steps 3 and 4 are handled by the existing Credo/AFJ framework already wired into heka-identity-service.
The Static Issuer: One Global Issuer for the Prototype
One design decision I want to explain upfront: we are not building multi-tenant dynamic issuer provisioning. This is a prototype, and the goal is to validate the end-to-end flow cleanly without admin overhead.
Instead, there is a single static global issuer — the pre-existing demo tenant (the same DEMO_USER account already used in heka-auth-service). On every application startup, a bootstrap sequence runs:
- Look up the demo user's wallet → get their Credo tenant ID
- Open their tenant agent
- Check if an OID4VCI issuer record exists for their
did:hederaDID - If not → create it (including the
GithubContributorCredentialprofile) - If yes → check if
GithubContributorCredentialis already in the supported list - If not → add it (idempotent)
- If yes → log and skip
The entire sequence is idempotent by design. Restarting the application never creates duplicate issuers or duplicate credential configurations. The bootstrap can fail gracefully (missing wallet, missing DID) without crashing the application — it just logs a warning.
This is the same pattern used by the PrepareWalletService in this codebase. When the infrastructure is ready, everything self-configures.
The GithubContributorCredential Profile
The credential type registered in the issuer metadata is GithubContributorCredentialSdJwt, with a VCT (Verifiable Credential Type) URI of:
https://hiero.ledger.org/vct/GithubContributorCredential
This URI is how wallets and verifiers identify what kind of credential they're dealing with. It's the same identifier defined in the Week 2 schema.
The credential profile declares all five claims:
claims: {
githubAccountId: { mandatory: true },
githubUsername: { mandatory: true },
gpgFingerprint: { mandatory: true },
verifiedAt: { mandatory: true },
walletId: { mandatory: true },
}
SD-JWT: Selective Disclosure in Practice
The credential format is SD-JWT VC (Selective Disclosure JWT Verifiable Credential). The "selective disclosure" part is what makes this interesting.
A standard JWT credential puts all its claims in the open. Anyone who receives it can see everything. An SD-JWT VC lets the holder choose which claims to reveal and which to keep private, on a per-presentation basis.
The "disclosure frame" is what controls this. It's a configuration that tells the signing framework which claims should be placed in cryptographically blinded digest groups (_sd) rather than revealed directly:
// Week 2 disclosure policy
const disclosureFrame = {
_sd: ['githubUsername', 'gpgFingerprint']
}
What this means in practice:
| Claim | Always in the credential? | Can the holder hide it? |
|---|---|---|
githubAccountId |
✅ Always revealed | No — it's the identity anchor |
verifiedAt |
✅ Always revealed | No — it's the audit timestamp |
walletId |
✅ Always revealed | No — it's the wallet binding |
githubUsername |
Digest only | ✅ Yes — holder can choose |
gpgFingerprint |
Digest only | ✅ Yes — holder can choose |
The design intent: a contributor should be able to prove they are a verified Hiero contributor (by showing their numeric account ID and verification timestamp) without necessarily revealing their GitHub handle or GPG key fingerprint to every verifier. The holder controls what gets shared.
Deterministic Verification Method Selection
When signing the SD-JWT VC, you need to select which verification method (which key) from the issuer DID document to sign with. If you pick differently each time, different verifiers may not be able to reproduce the check.
The approach here is simple and consistent with how the rest of heka-identity-service handles this: take the first verification method from the resolved DID document. For a did:hedera DID with a single key pair (which is the standard setup for the demo tenant), this is always the same key. Deterministic, predictable, no surprise.
The Credential Offer Endpoint
POST /v2/contributor-credential/offer
{ "githubAccountId": "12345678" }
The service:
- Looks up the
ContributorBindingfor the given GitHub account ID - Throws
404if no binding exists (contributor hasn't completed OAuth login) - Throws
409if the binding exists but GPG verification is incomplete - Resolves the demo tenant's
did:hederaDID - Builds the SD-JWT VC payload from the binding fields
- Applies the Week 2 disclosure frame
- Calls Credo's
createCredentialOffer()directly - Returns the
openid-credential-offer://URI
A key implementation detail on step 7: I'm calling tenantAgent.openid4vc.issuer.createCredentialOffer() directly rather than routing through OpenId4VcIssuanceSessionService.offer(). The reason is that the session service unconditionally calls statusListService.getOrCreate(authInfo) — which needs a full User entity from the database — before checking whether the credential even uses revocation. SD-JWT VCs don't support revocation (it's in the spec). Routing around the status list check for SD-JWT VCs is actually the architecturally correct thing to do here.
Two Bugs Found During Review
I did a deep review pass before calling Week 5 done. Two real bugs caught:
Bug 1: Wrong enum value (silent failure)
The updateIssuerMetadata call was using:
action: 'Add' as const // ❌ Wrong
The actual enum is:
UpdateIssuerSupportedCredentialsAction.Add = 'add' // lowercase
The switch statement in issuer.service.ts was hitting the default branch and silently doing nothing. The credential config would never have been registered on existing issuers. Fixed to use the proper enum import.
Bug 2: Fake AuthInfo crashing the status list service
Earlier I was constructing a minimal auth-info object to pass to the session service. The status list service does em.find(CredentialStatusList, { owner: authInfo.user }) — and authInfo.user was undefined, which would have been a runtime crash on every credential offer request. Fixed by bypassing the session service entirely (see step 7 above).
Both bugs were caught by reading the actual source of every dependency — not just the types. TypeScript alone won't catch a structural type that's missing a field when the missing field is only accessed at runtime inside a service you're calling.
Test Coverage
11 unit tests covering:
- Bootstrap creates issuer when demo wallet exists and no issuer is registered
- Bootstrap skips when credential config already registered
- Bootstrap logs warning without throwing when demo wallet is missing
- Bootstrap uses the correct
'add'enum value (explicit assertion) - Credential offer returns a valid
openid-credential-offer://URI - Payload carries all five Week 2 claims with correct values
- Disclosure frame contains exactly the two selectively disclosable claims
- Non-selectively-disclosable claims are absent from
_sd - Verification method from DID document is passed through (deterministic selection)
-
NotFoundExceptionwhen no binding exists -
ConflictExceptionwhen binding is GPG-unverified -
NotFoundExceptionwhen demo tenant wallet is missing
307 total tests, all passing.
How the Five Weeks Connect
Looking back at the full arc:
- Week 1: Project setup, infrastructure, development environment
- Week 2: Credential schema, disclosure policy, DID on Hedera testnet
- Week 3: GPG challenge-response — cryptographic proof of key ownership
- Week 4: Identity binding — tying GPG key, GitHub account, and Heka wallet together permanently
- Week 5: OID4VCI issuance — turning that binding into a real signed credential a wallet can hold
Each week was a necessary prerequisite for the next. You can't issue a credential without a schema (Week 2). You can't bind an identity without verifying the key (Week 3). You can't issue a meaningful credential without a completed binding (Week 4).
What's Next
Week 6 moves into the Web Wallet UI and the PR-gating flow — the two pieces that make the credential actually useful. A contributor will be able to present their GithubContributorCredential when opening a PR, and the GitHub App will check the verification status before allowing the PR to be merged.
The credential is now real. Next week it starts doing work.
See you then.
This is part of my LFDT Mentorship 2026 blog series. Each week I write about what I built, what I learned, and what surprised me. If you're working on decentralized identity, verifiable credentials, or open source supply chain security, I'd love to hear from you.
Top comments (0)