Cookbook · OpenID4VCI 1.0 Final + SD-JWT VC + HAIP

Turn your tenant's membership card into a Verifiable Credential in the European Digital Identity Wallet.

Members mint their own card from a self-serve page, your tenant signs it, and it lives inside the member's own wallet on their own device — presentable at the door, at renewal, at partner discounts, or for wallet-based sign-in. Single-use offer, tenant-signed, season-length validity, per-tenant VCT allow-list. No third-party dependency. Everything ships out of the box.

What ships out of the box.
  • Per-tenant OpenID4VCI 1.0 Final issuer at /vci.ashx with its own signing key at App_Data/<tenant>/vci/ (auto-generated on first use).
  • Default membership VCT urn:aloaha:vc:tenant-membership that projects the signed-in member's tenant + role from the OIDC user record.
  • Member-facing page at /member-card.html — sign in, click the button, scan the QR with your wallet.
  • Standard OID4VCI wire: single-use pre-authorized code offer, credential_offer_uri + openid-credential-offer:// deep-link, HAIP-conformant DPoP + attest_jwt_client_auth at the token endpoint.
  • SD-JWT VC output format — selective disclosure means the door bouncer can verify "yes, valid member" without seeing the member's date of birth.

1 Endpoints in play

Three endpoints do all the work. All are per-tenant and enforce the tenant host on the URL.

GET/vci.ashx?available-attributes
Bearer <member access_token>. Returns which VCTs the signed-in member is ready to issue (per ComputeReady). Use to render a "not ready" list on the UI when the member's OIDC profile is missing mandatory claims.

POST/vci.ashx?self-offer
Bearer <member access_token>. Body: { vct?, days? }. Server enforces subject = bearer.sub, projects OIDC claims via BuildClaimsForVct, checks the per-tenant allow-list, and returns { offer_id, offer_uri, deep_link, vct, subject, expires_utc }. The deep_link is openid-credential-offer://?credential_offer_uri=…. Rate-limited to one offer per 5 seconds per subject.

GET/vci.ashx?offer=<id>
Anonymous. The wallet fetches this after scanning the QR — standard OpenID4VCI credential-offer JSON with the pre-authorized code. Everything downstream (?token, ?credential, ?notification) is stock OID4VCI.

2 Enabling self-serve for your tenant

Nothing to enable if you are happy with the bare urn:aloaha:vc:tenant-membership credential (issued by default). To add other credential types — PID, verified email, resident address, or a tenant-specific VCT — drop this file into App_Data/<tenant>/vci/self-offer-allowlist.json:

{
  "vcts": [
    "urn:aloaha:vc:tenant-membership",
    "urn:eu.europa.ec.eudi:pid:1",
    "urn:eu.europa.ec.eudi:email:1"
  ]
}

Only VCTs listed here will accept the ?self-offer path from members. The admin-only ?create-offer is unaffected — admins can still mint any VCT the platform catalogue supports. The file is read on every self-offer call, so no restart is required.

Missing file → safe default of just urn:aloaha:vc:tenant-membership. Malformed file (bad JSON, unknown VCT) fails closed with a 403 or 400 rather than silently opening the door.

3 Populating members' cards

Because ?self-offer pulls credential claims from the member's OIDC user record, whatever your tenant populates on the OIDC user profile ends up on the credential. Typical wiring:

Credential claimOIDC source claimNotes
membership_numberpreferred_username or subStable per member.
given_name / family_namegiven_name / family_nameDerived from name if the pair is absent.
member_sincecreated_atCustom claim on your OIDC provider.
valid_untilmembership_valid_untilCustom claim, ISO 8601 date.
categoryrole or membership_categoryAdult / Youth / Senior / Family etc.

The default urn:aloaha:vc:tenant-membership emits tenant + role. To emit richer fields you either populate them on the OIDC user record (they flow through automatically once BuildClaimsForVct knows the field) or you register a new VCT with a bespoke claim map — contact your platform integrator for the code path.

4 Season-length validity

Credentials are issued with an expires_utc derived from days on the offer (default is the platform's OfferDefaultDays; max is OfferMaxDays). For a season-length card, pass days explicitly on the client — e.g. 305 for 1 August to 31 May — and the wallet will show the correct expiry on the credential tile.

fetch('/vci.ashx?self-offer', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer ' + accessToken,
             'Content-Type': 'application/json' },
  body: JSON.stringify({ days: 305 })
});

5 Revocation

Two mechanisms, pick whichever fits your membership lifecycle:

  • Non-renewal by expiry — simplest for yearly membership. Issue with days matching your season; the credential dies on its own.
  • OAuth Status List — long-lived credentials reference the tenant's status list; when a member is expelled or unpaid, flip the bit. Already shipped platform-wide (HAIP GAP 4).

6 Verification at the door

Two patterns, pick whichever suits the setting. Share-QR is the right pattern for physical-presence checks (the clubhouse door, a partner discount, a stall at an event). OID4VP is the right pattern when the wallet-holder is remote and you need cryptographic binding to a fresh nonce.

Pattern A — Share-QR verifier (physical-presence checks)

Screenshot: the CodeB Web Wallet on a phone showing a Verifiable Credential (urn:aloaha:vc:tenant-membership) with four tabs -- Visual, Details (JSON), Verify (QR) selected, and Present (OID4VP). A large scannable QR code fills the lower half of the screen. Caption: Payload 1.6 kB in the URL fragment. Single-scan, no wallet needed on the verifier side. Screenshot: the vc-verify.html public verifier on a phone, showing a green VALID verdict for urn:aloaha:vc:tenant-membership. Credential summary lists issuer, subject, issued and valid-until dates. Disclosed attributes: tenant, role=admin. Verification checks: Parse OK, Header alg ES256, Expiry not expired (valid until 2027-07-23), Issuer JWKS fetch OK from https://www.aloaha.com/vci.ashx?jwks, Signature valid ES256, Disclosures 2 matched 0 unmatched, Status list not referenced (revocation by expiry only).
Left: wallet-holder's Verify (QR) tab. Right: what any bystander sees a second after scanning — ES256 signature verified against the issuer's published JWKS, disclosures matched, revocation checked. Click either screenshot to enlarge.

The wallet-holder opens the credential in their wallet and taps the Verify (QR) tab. The wallet renders a QR containing a URL like:

https://<tenant>/vc-verify.html#c=<sd-jwt-vc>

Anyone with a phone camera scans that QR. Their browser opens /vc-verify.html, which does the following entirely in the verifier's browser — no sign-in, no wallet on the verifier side, no server round-trip for the credential itself:

  1. Parse the SD-JWT VC from the URL fragment (the credential rides after the #, which the browser never sends to the server).
  2. Extract the iss claim and fetch the issuer's public keys from <iss>/.well-known/jwt-vc-issuer (SD-JWT VC standard) or the CodeB convenience endpoint /vci.ashx?jwks.
  3. Pick the JWK matching the JWT header's kid.
  4. Verify the ES256 signature via WebCrypto.
  5. Check exp (expiry) and nbf (not-before).
  6. SHA-256 each selective disclosure and confirm the hash appears in the credential payload's _sd array (proves the disclosed value was in the signed set).
  7. If the credential references an OAuth Status List (status.status_list.uri + idx), fetch the status list JWT, verify its own signature, DEFLATE-decompress the bitmap, read the bit at idx. Fails closed on any missing/expired/bad-signature check.

Renders a green VALID / red INVALID card with the disclosed attributes and a per-check breakdown. Compatible with any SD-JWT VC issued by any CodeB tenant (JWKS lookup follows the credential's own issuer, not the verifier's host).

What Share-QR is right for. Door checks, kiosk validation, staff-badging, partner-discount confirmations, peer-to-peer proof-of-membership, ticket scanning. Anywhere the verifier can eyeball the wallet-holder while checking, or where the credential itself carries a photograph that the verifier can match to the person in front of them.
What Share-QR is NOT right for. Remote authentication. A copy of the QR is as verifiable as the original — a screenshot would let someone else pass as the holder. If your use case is unattended sign-in or authorising a purchase from a phone the verifier can't see, use Pattern B (OID4VP) which binds each presentation to a fresh verifier-supplied nonce and the wallet's own holder key.

Pattern B — OpenID4VP verifier (remote / high-security)

Standards-track OID4VP against your tenant's verifier endpoint. See the verifier integration cookbook for the full recipe; a "check my member card" page can be built in an afternoon using the existing DCQL query for urn:aloaha:vc:tenant-membership. The verifier hosts a QR containing an OID4VP request; the wallet-holder scans that QR (opposite direction from Pattern A), the wallet builds a nonce-bound presentation, the verifier confirms.

Standards conformance

The Share-QR verifier implements SD-JWT VC (IETF draft-ietf-oauth-sd-jwt-vc), OAuth Status Lists (IETF draft-ietf-oauth-status-list), and JWA ES256 (RFC 7518). Signature verification runs on WebCrypto (W3C), bitmap decompression on DecompressionStream (WHATWG Compression Standard). No third-party JavaScript libraries. Zero external network calls other than the issuer JWKS fetch and the optional status-list fetch, both of which follow the credential's own iss.

7 Security posture

  • Subject binding — the member cannot mint a credential for anyone else. The bearer's sub is authoritative; the client cannot override it.
  • VCT allow-listing — only VCTs the tenant has opted in are self-serviceable.
  • Claim projection — the client cannot inject arbitrary claims; every credential claim is server-derived from the OIDC user record via BuildClaimsForVct.
  • Single-use — each offer is single-use with a short pre-authorized code TTL. A leaked deep-link is worthless after redemption or expiry.
  • Rate-limit — one offer per 5 seconds per subject; the tenant is never floodable.
  • Per-tenant isolation — offers live in App_Data/<tenant>/vci/offers/; the credential is signed with the tenant's own issuer key; no cross-tenant leakage.

8 Standards conformance

OpenID for Verifiable Credential Issuance (OID4VCI) 1.0 Final, OpenID for Verifiable Presentations (OID4VP), SD-JWT VC (IETF), HAIP profile of OID4VC. Compatible with the ARF 3.0 reference wallets and any conforming European Digital Identity Wallet.

Related pages. member-card.html is the shipped member-facing page. verifier-integration-cookbook.html covers the presentation side. eu-wallet-integrations.html covers the wider wallet story. logineu.html handles wallet-based sign-in (which can present a member's own membership card as an assertion).