Verifiable credentials and wallets (OID4VC)
This page is the starting point for everything iya-sts does with digital wallets: issuing credentials into a wallet, asking a wallet to present one, signing a person in with a wallet, and the DIDs involved. It is written for an operator or integrator who has not seen the code. It explains what each part does, sets out the common tasks as recipes, walks through the admin console and the user portal, and shows how to connect a web wallet and a native wallet.
The detail lives on four pages, and this one links to them rather than repeating them:
- Verifiable credentials and wallets (OID4VC)
- What iya-sts does
- Before you start
- Use cases
- Issue a credential to a person’s wallet from the portal
- An operator issues or offers a credential
- Verify a presentation
- Sign in with a wallet
- Sign in with a self-issued ID (SIOPv2)
- Enrol a wallet key or DID
- A preferred DID and the
didclaim - Bind credentials to the person’s DID
- Suspend, reinstate and revoke issued credentials
- Set it up in a trust realm
- Development mode and product mode
- The admin console, page by page
- The user portal, step by step
- Integrating wallets
- HAIP
- Settings
- Endpoints
- Error codes you will meet
- Limits and what is not supported
- Departures from the specifications
- Related
What iya-sts does
| Role | Specification | Where | Read |
|---|---|---|---|
| Credential Issuer | OpenID4VCI 1.0 | /.well-known/openid-credential-issuer, /oid4vci/*, the token endpoint |
OpenID4VCI |
| Verifier | OpenID4VP 1.0, DCQL | /oid4vp/*, /admin-api/verifications |
OpenID4VP |
| Wallet sign-in | OpenID4VP 1.0, the W3C Digital Credentials API | /authn/wallet |
OpenID4VP |
| Relying party of a Self-Issued OP | SIOPv2 | /authn/wallet, /portal/self-issued |
OpenID4VP |
| Status publisher | IETF Token Status List, W3C Bitstring Status List | /oid4vci/status-lists* |
OpenID4VCI |
| DID publisher | W3C DID Core, DIF Well Known DID Configuration | /.well-known/did.json, /.well-known/did-configuration.json |
OpenID4VP |
| HAIP | OpenID4VC HAIP 1.0 | defaults and settings, not a mode | HAIP below |
iya-sts holds no wallet. It issues to wallets, verifies what wallets present, and accepts self-issued ID Tokens that wallets sign. It is never the Self-Issued OP itself. To try the flows you need a wallet; Integrating wallets covers two that have been used against it.
As an issuer
The issuer offers five fixed credential configurations in three formats:
SD-JWT VC (dc+sd-jwt), jwt_vc_json, and ldp_vc secured with bbs-2023.
mso_mdoc is not issued. The configurations are not configurable; what you
choose is which claims they carry, taken from the person’s directory
entry. The issuer metadata is the live list:
| Configuration | Format | Scope |
|---|---|---|
IdentityCredential |
dc+sd-jwt |
identity_credential |
IdentityCredentialJwtVcJson |
jwt_vc_json |
identity_credential_jwt |
IdentityCredentialLdpVc |
ldp_vc |
identity_credential_ldp |
IdentityCredentialDid |
dc+sd-jwt, issuer named by did:web |
identity_credential_did |
IdentityCredentialLdpVcDid |
ldp_vc, issuer named by did:web |
identity_credential_ldp_did |
A wallet collects a credential in one of three ways:
- A pre-authorized offer with a Transaction Code, which a signed-in person makes for their own wallet on the user portal’s Add to your wallet page (OpenID4VCI Appendix H.2, or H.3 for deferred issuance). This is the usual way, and it needs no wallet registration.
- The authorization code flow, which the wallet starts itself for a configuration’s scope. The wallet must be registered as an OAuth client.
- A same-device Credential Offer that your own issuer web site builds, telling the wallet to start the authorization code flow (Appendix H.1).
Every credential carries a status entry, can be suspended or revoked, and is recorded in an issued-credentials register. Only a credential in that register can later sign anybody in.
As a verifier
The Verifier asks for a credential with a DCQL query and checks everything it can: the issuer signature, every SD-JWT disclosure, the holder’s key-binding proof, the nonce, the audience, the validity window, the requested claims and the credential’s status. It trusts this realm’s own issuer by default, and other issuers whose certificates you add. A verification is started through the console or the Management API, and its verdict is recorded. A verification signs nobody in.
Signing in with a wallet
Sign in with a wallet on the sign-in screen is the Verifier used as an
authentication method. It is on by default (oid4vp.signIn). It signs a
person in only with a credential this realm issued to them, as recorded in
the issued-credentials register, never on the strength of what the
credential’s own sub says. Every format signs in, each with its own proof
of the holder key. The default request is signed under the x509_hash
Client Identifier and answered encrypted (direct_post.jwt), which is what
HAIP 1.0 has a Verifier send.
Signing in with a self-issued ID (SIOPv2)
With oid4vp.signInSelfIssued on (it is off by default), the sign-in screen
also offers Sign in with a self-issued ID. The wallet answers with an ID
Token signed by its own key. Nobody vouches for that key, so it signs in only
the person it was enrolled for: the person proves it from the portal, or
an administrator enrols it by value. A DID the person proved can then be
their preferred DID, released to applications as the did claim, and a
credential configuration can be bound to it.
DIDs
Each realm publishes a did:web for its issuer, with a Domain Linkage
Credential that ties it to the origin. An application can have a did:web of
its own. A person’s DIDs (did:jwk, did:key, did:web) are enrolled as
self-issued subjects. This service does not host a did:web for a person;
that is ticket #775 and is not built.
Before you start
The service Root is the one trust anchor. The service’s TLS certificate,
the Verifier’s request-signing certificate (x5c in a signed request) and
the credential signing certificate (x5c in a credential and in its Status
List Token) all chain to one Root CA, and the Root itself is never in those
chains. A wallet that checks any of them must be given that Root:
# PEM, from the Verifier's certificate document
curl -sk https://sts.example/oid4vp/verifier-certificate | jq -r .trust_anchor_pem > sts-root.pem
# or DER, from the PKI
curl -sk https://sts.example/pki/ca/service/root.cer | openssl x509 -inform DER -out sts-root.pem
In development mode the Root is rebuilt at every start, so a wallet that was given it must be given the new one after a restart. In product mode it survives restarts. See TLS for adding it to a browser.
Fix the public address. The realm’s did:web, the issuer identifier and
the x509_san_dns name follow the address the service was reached at unless
global.publicBaseUrl is set. Set it in any deployment a wallet will remember.
A person needs a directory entry, and in product mode a password, before
they can sign in to the portal and make an offer. See
Configuring OpenID4VCI
for an example users/create call.
Use cases
Each recipe says who does what, where. The console path is
Protocols → Verifiable Credentials (/admin/vc) unless another page is
named. The Management API calls use the api shell helper described in
Configuring OAuth 2.0 grants.
Issue a credential to a person’s wallet from the portal
Nothing needs configuring: Add to your wallet is on in every realm and in both modes.
- Optionally, choose the claims: on the Credential claims tab, tick the attributes and click Save this selection. One selection applies to all five configurations.
- The person signs in to
/portal, opens Add to your wallet, picks a credential (Identity Credential,dc+sd-jwt, is the one most wallets hold) and clicks Make an offer. - Their wallet scans the QR code, or they click Open in a wallet on this device, and type the Transaction Code shown beside it.
The user portal below has every control on the page. The wallet’s own requests are in Configuring OpenID4VCI.
An operator issues or offers a credential
There is no console page or Management API operation that makes a pre-authorized offer for somebody else, in either mode. A pre-authorized code is an authorization, so only the person, signed in, can have one made for their own wallet. What an operator can do:
- Run an issuer web site that hands the wallet a same-device offer for the authorization code flow. The person then signs in at this service’s authorization server. Register the wallet as a public OAuth client first. See A same-device Credential Offer and Authorization code flow.
- Issue through the W3C VC API (
/vc-api), which is off by default (vcApi.enabled) and signs a credential the caller supplies, with keys of its own. See W3C VC API. - In development only, use the companion, a separate container whose sample issuer page makes a same-device offer and links the person to the portal for a pre-authorized one.
Verify a presentation
A verification is a relying party’s presentation request. It records a verdict and the holder, and starts no session.
- Console: on the Verifier request tab, choose what to ask for under The claims and save it, then use Start a verification (format, request by value or by reference, and optionally the Client Identifier prefix, response type, response mode and device) and click Start. The request appears under Open verifications, whose Outcome changes from waiting for the wallet to accepted or refused.
- API:
POST /admin-api/verifications/start(Admin Write), thenGET /admin-api/verifications?state=<state>(Admin Read) for the verdict.
api verifications/start '{"format":"dc+sd-jwt","by":"reference",
"client_id_prefix":"x509_hash","response_mode":"direct_post.jwt",
"redirect_uri":"https://verifier.example/verified"}'
# → state, requestUri (openid4vp://… for a QR code or a link), walletUrl, request_uri, …
curl -sk "$BASE/admin-api/verifications?state=$STATE" -H "Authorization: Bearer $TOKEN"
redirect_uri is your page, where the wallet is sent after it answers; in
product mode it must be one of the calling client’s registered redirect URIs.
By default the Verifier trusts only this realm’s issuer: add another issuer’s
PEM certificate to oid4vp.trustedIssuerCertificates. Every presented
credential must name a status that resolves VALID
(oid4vp.requireStatusReference, all). Details:
Start a verification,
Trust another issuer.
Sign in with a wallet
On by default. What has to be true:
- The person holds a credential this realm issued to them on an access token it verified, for example from Add to your wallet. A credential from another realm or another issuer verifies and signs nobody in.
- The wallet trusts the service Root (the request is signed under
x509_hashand carries the Verifier’s chain). - The application the person is signing in to is registered, as for any sign-in.
The person chooses Sign in with a wallet on the sign-in screen, then one of:
- Use a wallet on this or a nearby device: the browser’s Digital Credentials API, the default and the safest path;
- Open your wallet on this device: an
openid4vp://link for a wallet app; - Open your web wallet: only where
oid4vp.walletUrlis set; - Show a QR code instead: only where
oid4vp.signInCrossDeviceis on. It is off because a QR code can be relayed to a victim.
The session records amr ["pop"], or more where a key attestation the issuer
verified says so. A wallet can be followed by a second factor, or be the
second factor after a password. To send one application’s people straight to
the wallet, point that application’s appAuthnPolicy at a named
authentication policy
whose only first factor is wallet. See
Sign in with a wallet and
What the session says.
Sign in with a self-issued ID (SIOPv2)
- Turn it on: set
oid4vp.signInSelfIssuedon the OpenID4VP tab and click Save OID4VP, orapi config/set '{"key":"oid4vp.signInSelfIssued","value":true}'. - The person enrols their wallet’s key (next recipe).
- On the sign-in screen they choose Sign in with a self-issued ID, and the
wallet answers a
siopv2://request with an ID Token signed by the enrolled key.
The ID Token’s subject may be a JWK thumbprint, a did:jwk, a did:key or a
did:web. What is checked is in
Self-issued ID Tokens.
Enrol a wallet key or DID
A self-issued subject belongs to one person per realm, and a person may hold ten.
- The person, by proof (the only way that counts for the
didclaim and for DID holder binding): on/portal/self-issued, Enrol a wallet. The wallet answers a SIOPv2 request from the session they already hold, and the key that signed it is enrolled. Nothing is typed. Adid:webis fetched once for the enrolment and its key is pinned. Needsoid4vp.signInSelfIssuedon. - An administrator, by value: on the person’s page under
Directory → Users, section Self-issued IDs, enter the Subject
and a Label and click Enrol (Admin Write). The row says By value,
unproven. A
did:webis fetched as you enrol it and its authentication keys are pinned; if it cannot be fetched, the enrolment is refused (STS-ADMIN-0871). API:
api users/enrol-self-issued-subject '{"user":"alice","subject":"did:jwk:eyJ…","label":"phone"}'
curl -sk "$BASE/admin-api/users/self-issued-subjects?user=alice" -H "Authorization: Bearer $TOKEN"
api users/remove-self-issued-subject '{"user":"alice","subject":"did:jwk:eyJ…"}'
Proving a subject that is already enrolled proves it again. That is how an
administrator’s entry becomes a proved one, and how a did:web whose
document now names another key is re-pinned. Until it is re-proved, a
sign-in with the new key is refused (STS-VC-0126).
A preferred DID and the did claim
A person may mark one DID they proved as preferred. With oauth2.didClaim
on, that DID is released as the did claim in the ID Token and the UserInfo
response.
- The person proves the DID (above), then clicks Make preferred beside it
on
/portal/self-issued. An administrator can do the same from the person’s page, or withapi users/set-preferred-self-issued-subject '{"user":"alice","subject":"did:jwk:eyJ…"}'(nosubjectclears it). - Turn the claim on:
api config/set '{"key":"oauth2.didClaim","value":true}'. - Add
didto the client’soauthAllowedScope. This is needed in both modes. - The client asks for
scope=openid did, and the person consents on the consent screen.
A JWK thumbprint and an administrator’s by-value entry cannot be preferred
(STS-PORTAL-0615, STS-ADMIN-0872). A client using pairwise or ephemeral
subjects is not told the DID unless its oauthDidClaimWithPairwise is
TRUE. The claim is never put in a SAML assertion. When any condition fails,
the claim is left out, never an error. Every condition is in
Release a person’s DID as a claim.
Bind credentials to the person’s DID
By default a credential is bound to the key the wallet’s proof names, with a
different key for each copy of a batch. A configuration named in
oid4vci.didHolderBinding binds instead to a did:jwk or did:key the
person proved on /portal/self-issued.
api config/set '{"key":"oid4vci.didHolderBinding","value":"IdentityCredential"}'
The wallet’s key proof must then name the DID in its kid
(did:jwk:…#0, did:key:z…#z…) instead of carrying a jwk. Refusals are
invalid_proof: a jwk proof (STS-VC-0145), a DID an administrator typed
in (STS-VC-0149), somebody else’s (STS-VC-0150), one enrolled for nobody
(STS-VC-0148), and a did:web (STS-VC-0142), which is not offered.
Every copy then carries the same DID, so any two can be linked. Neither
wallet described below sends a kid proof, so neither can collect a
DID-bound credential. See
Binding a credential to the person’s DID.
Suspend, reinstate and revoke issued credentials
- The operator: Monitoring → Verifiable Credentials
(
/admin/vc/monitor), Credentials tab: Suspend, Reinstate or Revoke a credential by its status-list index. Revoked is final. API:api vc-status/suspend '{"idx":4711}'(andreinstate,revoke;GET /admin-api/vc-statuslists them). - The person: Remove on Add to your wallet revokes the credential (or one copy, or all copies of a batch). It is not deleted from the wallet: no protocol lets an issuer do that, and the page tells the person to delete it there.
- Also: a revocation on Tokens (
/admin/tokens), an administrator’s global sign-out, and a disabled account. The person’s own global logout at/logoutdoes not change a credential’s status.
A credential that is not VALID is refused by every verifier that reads its status list, and signs nobody in here. See Status lists.
Set it up in a trust realm
Every trust realm is a separate issuer and Verifier with its own metadata, keys, offers, status lists, sign-in register, DID and settings:
| Default realm | Realm acme |
|
|---|---|---|
| Issuer identifier | https://sts.example |
https://sts.example/realm/acme |
| Issuer metadata | /.well-known/openid-credential-issuer |
/.well-known/openid-credential-issuer/realm/acme |
| DID | did:web:sts.example |
did:web:sts.example:realm:acme (document at /realm/acme/did.json) |
| Console | /admin/vc |
/realm/acme/admin/vc |
| Settings by API | /admin-api/config/set |
/realm/acme/admin-api/config/set |
| Portal | /portal/wallet |
/realm/acme/portal/wallet |
A setting changed in one realm does not change it in another. A credential issued by one realm verifies at another realm’s Verifier only if that realm trusts the issuer’s certificate, and it never signs anybody in there.
Development mode and product mode
The Verifier’s checks, the sign-in rules and the status checks are the same in both modes. What differs:
| Development | Product | |
|---|---|---|
| Access token at the credential, deferred and notification endpoints | Read without verifying it; a credential issued this way signs nobody in | Must verify against this realm’s key and not be revoked, else invalid_token |
| Claim values a directory entry lacks | Invented, deterministically and in fictional ranges | Left out |
| Wrong Transaction Codes | Not counted | The oid4vci.txCodeMaxAttemptsth (5) spends the code |
| Signing keys and the service Root | New at every start | Kept, sealed, and rotated on a schedule |
wallet named by a verification start |
Any URL | oid4vp.walletUrl or one in oid4vp.allowedWalletUrls |
redirect_uri named by a verification start |
Any absolute http(s) URL | One of the calling client’s registered redirect URIs |
x509_san_dns with no configured name |
The request’s host | Refused |
A did:web enrolled before key pinning |
Pinned at its next sign-in | Refused until enrolled again (STS-VC-0127) |
oid4vp.requireStatusReference=off |
Allowed | Refused, and a stored off is read as all |
The full lists are in OpenID4VCI, OpenID4VP and What is not checked.
The admin console, page by page
Everything below needs a console role (/admin); changing anything needs
Admin Write. Each control has a Management API twin, listed at the end.
Protocols → Verifiable Credentials (/admin/vc)
One page with five tabs.
- Metadata lists the issuer and Verifier endpoints of this realm, with links to the live documents.
- OpenID4VCI holds every
oid4vci.*setting, and the twovcApi.*settings, saved with Save OID4VCI (and Save W3C VC API). The ones you are most likely to set:oid4vci.walletUrlandoid4vci.walletIssuancePath: a web wallet, offered as Open in the web wallet beside the wallet-app link.oid4vci.walletInvocation:haip-vcito hand offers over underhaip-vci://.oid4vci.credentialSigningAlgorithm: RS256 by default; ES256 for HAIP.oid4vci.batchSize: copies per issuance (4; 1 turns batches off).oid4vci.didHolderBinding: the configurations bound to a proved DID.oid4vci.keyAttestationTrustedCertificatesandoid4vci.keyAttestationRequired: wallet-provider key attestations.oid4vci.sdJwtIssuerDid,oid4vci.ldpVcIssuerDid: name the plain configurations’ issuer bydid:web. These two take effect after a restart.
- Credential claims chooses which directory attributes every credential carries: tick them in the In column and click Save this selection. Saving also fills those attributes on every person’s entry where they are missing (invented values in development, nothing in product). Preview the credential for shows one person’s credential as it would be now. Restore the six default claims goes back to the default.
- OpenID4VP holds every
oid4vp.*setting, saved with Save OID4VP: the Verifier’s Client Identifier, the trusted issuers, the status rules, the wallet URLs, and all the wallet sign-in and SIOPv2 settings. - Verifier request chooses what the Verifier asks for (The claims,
Save this request; claims outside the catalogue under Also ask for a
claim that is not in the catalogue), and in which format by default. The
query this builds shows the next
dcql_query. Below are Start a verification and Open verifications.
Monitoring → Verifiable Credentials (/admin/vc/monitor)
Two tabs. Metadata shows the counts (valid, suspended, revoked), where this realm’s status lists are served and their lifetimes, and Publish now for each Bitstring Status List. Credentials lists every issued credential with its Index, Format, Status, Changed by and Expires, and Suspend, Reinstate and Revoke.
Directory → Users (/admin/users?user=<name>)
On a person’s page, the section Second factors, and what this person can
sign in with has a Self-issued IDs block: each enrolled subject with
How (Proved by the person’s wallet or By value, unproven (an
administrator)), Location, Preferred DID with Make preferred or
Clear, and a remove button; and the form to enrol one by value. The block
says whether oid4vp.signInSelfIssued is on. The users list also shows every
holder whose presentation verified, as it shows every other identity that
authenticated.
Directory → Applications
- A wallet as an OAuth client, for the authorization code flow: a public
client declared for OAuth 2.0 and OpenID4VCI,
oauthTokenEndpointAuthMethodnone,authorization_code, and the configurations’ scopes inoauthScope. Not needed for a pre-authorized offer. See Configuring OpenID4VCI. walletas the only first factor of the named authentication policy an application names inappAuthnPolicysends its people straight to the wallet sign-in.oauthAllowedScopedidlets a client be told the preferred DID.- An application’s own DID: declare it for the DID family and generate a key on its Credentials tab. See An application’s DID.
Other pages that show wallet activity
- Tokens (
/admin/tokens): issued credentials among everything else this realm issued, with the revocation that disowns one. - Sessions (
/admin/sessions): wallet sign-ins, like any other session. - Sign-out (
/admin/logout): a global sign-out disowns the person’s credentials, and each wallet credential’s register row can be ended on its own. /admin/sts-metadata: every endpoint, read from the running router.
The same controls through the Management API
Every oid4vci.*, oid4vp.* and oauth2.didClaim setting is changed with
POST /admin-api/config/set (or config/set-many, config/reset). The rest:
| Operation | What it does |
|---|---|
GET /admin-api/oid4vci-settings, GET /admin-api/oid4vp-settings |
Read the two settings tabs |
GET /admin-api/credential-claims, POST …/credential-claims/{select,add,remove,defaults} |
What a credential carries |
GET /admin-api/verifier-request, POST …/verifier-request/{select,add,remove,defaults,format} |
What the Verifier asks for |
POST /admin-api/verifications/start, GET /admin-api/verifications |
Start a verification; read its verdict, or list the open ones |
GET /admin-api/vc-status, POST …/vc-status/{suspend,reinstate,revoke,publish} |
Issued credentials and their status |
GET /admin-api/oid4vci/notifications?id=, GET /admin-api/oid4vci/last-request |
What a wallet reported to the Notification Endpoint; how the last credential request arrived |
GET /admin-api/users/self-issued-subjects?user=, POST …/users/{enrol,remove}-self-issued-subject, POST …/users/set-preferred-self-issued-subject |
A person’s self-issued subjects |
GET /admin-api/openapi.json describes each one. See
Management API for getting a token.
The user portal, step by step
The portal (/portal, or /realm/<id>/portal) is the person’s own account
page. Both pages below act only for the person signed in; nobody else can be
named. Their API is the account API.
Add to your wallet (/portal/wallet)
- Under Which credential, choose a configuration. The names are the issuer metadata’s; issuer named by DID marks the two DID configurations.
- Under When it is issued, choose As soon as your wallet asks for it, or Later: your wallet is told to come back for it (deferred issuance).
- Under Signing algorithm, keep the configured default or choose another the configuration allows. Post-quantum algorithms are defined by drafts, and a wallet or verifier may not accept them yet.
- Click Make an offer. The page shows, once:
- Scan this with your wallet: a QR code of the offer by reference;
- the Transaction Code, which is not in the QR code;
- Open in a wallet on this device: the offer by value, as an
openid-credential-offer://link (orhaip-vci://); - Open in the web wallet: only where
oid4vci.walletUrlis set; - The offer, as a link.
The offer is good for
oid4vci.offerTtlS(ten minutes) and can be redeemed once. Reloading the page does not show it again; make a new one. - The wallet redeems the offer and asks for the Transaction Code.
Under Credentials issued to you, every credential this realm still records for the person is listed, newest first, one row per issuance, with its Status and what Your wallet reported to the Notification Endpoint (Stored it, Could not store it, Deleted it). With batch issuance on, the copies are folded under the row as Copy 2 of 4, each with the start of its wallet key’s thumbprint and its status-list entry. Remove revokes the credential (Remove all copies, or Remove this copy). The person then deletes it in the wallet.
An offer is made on the sign-on session the portal is using. If that session
is no longer known, the offer is refused (STS-PORTAL-0590); signing in again
fixes it.
Self-issued IDs (/portal/self-issued)
The page lists the wallet keys that sign the person in, one row each:
| Column | What it shows |
|---|---|
| Subject | The subject, shortened to its start and end; hover for all of it. Under a DID, a DID document link (below) |
| Label | The enrolment’s label |
| Enrolled | When |
| How | Proved with your wallet or Added by an administrator |
| Location | Where the key came from: a web wallet’s host and port (hover for its address), the name a wallet app reported in its User-Agent (labelled as reported), Browser (DC API), Administrator, or Not recorded for an enrolment made before this was recorded |
| Preferred DID | Preferred, or Make preferred / Clear on a proved DID |
| Remove |
- Enrol a wallet (shown while
oid4vp.signInSelfIssuedis on) opens the wallet sign-in page in enrolment mode, with the same choices as a sign-in: the Digital Credentials API button, Open your wallet on this device (siopv2://), the web wallet where one is configured, and the QR code where it is on. The wallet signs an ID Token and the key is enrolled. Nothing is typed. The page returns with Your wallet’s key is enrolled. - DID document opens in a new tab. For a
did:webit is the published document at the DID’s own address. For adid:jwkordid:keyit is/portal/self-issued/did?subject=…, the document resolved from the identifier itself, with nothing fetched. A JWK thumbprint has no link. - Make preferred marks one proved DID as the one the
didclaim releases. The note under the table says whether this realm releases it (oauth2.didClaim). Clear removes the mark. - Remove takes a subject off; it no longer signs the person in. Removing the preferred subject clears the mark.
If signing in with a self-issued ID is off, the page says so and offers no enrolment; the subjects already enrolled stay.
Integrating wallets
How a request reaches a wallet
| What iya-sts is doing | Wallet app link | Web wallet link | Other ways |
|---|---|---|---|
| Credential Offer (portal) | openid-credential-offer://?credential_offer=…, or haip-vci:// |
oid4vci.walletUrl + oid4vci.walletIssuancePath + ?credential_offer=… |
QR code of …?credential_offer_uri=… |
| Wallet sign-in, verification | openid4vp://?client_id=…&request_uri=…, or haip-vp:// |
oid4vp.walletUrl + oid4vp.walletPresentationPath + ?client_id=…&request_uri=… |
Digital Credentials API; QR code where on |
| SIOPv2 sign-in or enrolment (ID Token alone) | siopv2://?client_id=…&request_uri=… |
as above | Digital Credentials API; QR code where on |
- No web wallet is offered unless one is configured.
oid4vp.walletUrlfalls back tooid4vci.walletUrl, so one setting covers both unless you set the second. - The Digital Credentials API (
navigator.credentials.get) is how a browser or platform wallet is reached. The request is the signedopenid4vp-v1-signedform, whoseexpected_originsnames this service, and the answer is encrypted by default (dc_api.jwt). A browser without the API says so, and the link is the way on. - The cross-device QR code for a sign-in is off
(
oid4vp.signInCrossDevice), because it can be relayed: whoever scans it signs in the browser that showed it. The portal’s offer QR code is not affected; the Transaction Code protects it. - In product mode, a verification start that names a different
walletis refused unless that URL isoid4vp.walletUrlor is listed inoid4vp.allowedWalletUrls(STS-VC-0033).
What any wallet needs
To take a credential and sign in with it, a third-party wallet must:
- Trust the service Root (see Before you start) for
the TLS connection, for the
x5cchain of a signed request, and to check the issuer chain in a credential. A wallet in the EUDI ecosystem can instead read the issuer’s signed trusted list at/oid4vci/trusted-listonce it trusts the Root as the list’s operator. - Redeem a pre-authorized code without a client (the metadata says
pre-authorized_grant_anonymous_access_supported: true), or, if it names aclient_id, be registered in product mode. - Send a
jwtkey proof (openid4vci-proof+jwt) with a freshc_noncefrom/oid4vci/nonce. A batch of up tooid4vci.batchSizeproofs gets a credential per proof. - Open a request signed under
x509_hash(the sign-in default) and encrypt its answer fordirect_post.jwt. The request offers an X-Wing (HPKE-10-KE) key and an ECDH-ES P-256 key; a wallet with only ECDH-ES uses the second. A wallet that cannot do either needsoid4vp.signInClientIdPrefix=pre-registeredoroid4vp.signInResponseMode=direct_post. - Answer a DCQL query that offers one credential query per format in
oid4vp.signInFormatsand accepts any one. A wallet that understands onlydc+sd-jwtcan be helped byoid4vp.signInFormats=dc+sd-jwt. - For SIOPv2, sign an ID Token whose subject is one of the types in the
request’s
subject_syntax_types_supported(JWK thumbprint,did:jwk,did:key,did:web), withissequal tosub, the request’snonceand the Verifier’s Client Identifier asaud.
A web wallet: the EUDI Dev Wallet
The EUDI Dev Wallet (eudi) is a test
wallet with a web UI, a CLI and an HTTP API. It speaks OpenID4VCI 1.0,
OpenID4VP 1.0 and HAIP 1.0, holds SD-JWT VC, jwt_vc_json and mdoc
credentials, and has a debug mode (the default) that reports what an issuer
or verifier gets wrong and carries on, and a strict mode that refuses. It is
for testing only: its own documentation says it stores keys unencrypted and
must never hold real credentials.
Install and start it (from its README):
brew install eudi-dev/tap/eudi-dev # or a release binary, or: go build -o eudi .
eudi wallet serve --pid # web UI at http://localhost:8085
It listens on 127.0.0.1:8085 and keeps its state in ~/.eudi-dev/wallet/.
--register also registers it for openid4vp://, haip-vp://,
openid-credential-offer:// and haip-vci:// links on macOS and Linux.
Point iya-sts at it. The wallet answers presentation requests at
/authorize and credential offers at /credential-offer:
api config/set-many '{"oid4vci.walletUrl":"http://localhost:8085",
"oid4vci.walletIssuancePath":"/credential-offer",
"oid4vp.walletPresentationPath":"/authorize"}'
oid4vp.walletUrl is left empty and falls back to oid4vci.walletUrl. The
portal now shows Open in the web wallet, and the wallet sign-in page
Open your web wallet.
Trust. In debug mode the wallet does not verify HTTPS certificates and
reports untrusted chains as warnings. To check this service’s chains, give it
the Root: --tls-ca sts-root.pem for HTTPS in strict mode, and, for the
credentials and their status lists, the trusted list:
eudi trust add-ca --list trusted-list-ca --ca sts-root.pem
eudi trust add-list https://sts.example/oid4vci/trusted-list
Flows:
- Issuance: make an offer on Add to your wallet, click Open in the
web wallet, type the Transaction Code in the wallet’s consent dialog and
approve. The wallet also accepts the offer by reference, so the QR code
works with its
wallet scan, and it collects a deferred credential. - Wallet sign-in: Sign in with a wallet, then Open your web wallet, and approve in the wallet’s consent dialog. The wallet sends the browser back to the wait page, which finishes the sign-in.
- SIOPv2: the wallet signs its self-issued ID Token with its own holder
key and names it by the key’s JWK thumbprint, not by a DID
(
internal/wallet/idtoken.go). It enrols and signs in as a thumbprint, so it cannot be a person’s preferred DID. - Not usable with it: DID holder binding (its key proofs carry a
jwk), and a combinedvp_token id_tokenrequest: it binds each issued credential to a fresh key, while its ID Token is signed by its holder key, and this service requires the two to be the same key.
Two cautions. The wallet’s Docker image derives its keys from a public
seed (eudi-dev), so its holder key is the same in every copy of the image:
never enrol that key for a person whose account matters. And its --haip
check reports a signed request under any prefix but x509_hash as a finding
(strict mode refuses it). Only an administrator’s verification sends one by
default; the sign-in’s x509_hash passes.
Configuring OpenID4VCI describes a hand run of the portal with this wallet (not a test job).
A native wallet: OID4VC Wallet for Linux
OID4VC Wallet (oid4vc-wallet) is a
desktop wallet for Linux, written to exercise iya-sts. It stores credentials
sealed with AES-256-GCM under a key held by GNOME Keyring, KWallet or two
FIDO2 security keys. Its user guide is at
https://rcbj.github.io/oid4vc-wallet/.
Install it (from its README; building needs a stable Rust toolchain and
libudev-dev):
./install/install.sh # builds it and registers it for this user
sudo ./install/install.sh --system # or for every user
./install/appimage.sh && ./dist/OID4VC_Wallet-0.1.0-x86_64.AppImage install # or as an AppImage
The installer registers it for openid-credential-offer://, openid4vp://
and siopv2://; with --haip (or Also register haip-vci:// and haip-vp://
on Settings → Registration) also for haip-vci:// and haip-vp://.
Nothing needs configuring on iya-sts: it uses the wallet-app links, which are
the default.
Trust. It accepts the TLS certificate of an issuer on localhost,
127.0.0.1 or ::1 without further setup, so a local iya-sts works. For any
other host, add the service Root on Settings → Trust anchors.
Flows:
- Issuance: on Add to your wallet, click Open in a wallet on this device. The wallet opens on Accept and asks for the Transaction Code; type it and choose Redeem. It stores every copy of a batch, each with its own key. It does not fetch an offer by reference, so the QR code (which is by reference) cannot be used with it, and it does not wait for deferred issuance: choose As soon as your wallet asks for it.
- Wallet sign-in: Sign in with a wallet, then Open your wallet on
this device. The wallet fetches the request, checks it against the
x509_hash(orx509_san_dns) Client Identifier, picks a matching credential, asks Use this credential?, and answers encrypted with ECDH-ES. It presentsdc+sd-jwtcredentials only. - SIOPv2: Enrol a wallet on the portal, or Sign in with a self-issued
ID, then Open your wallet on this device. The first
siopv2://request creates a self-issued key and stores it as a credential. Settings → Security → Self-issued ID subject chooses how the ID Token names it: JWK thumbprint (the default),did:jwk,did:keyordid:web. Fordid:web, the tab shows the DID document to publish and the https address to serve it at; iya-sts fetches it once at enrolment and pins the key (in product mode not from an address inside its own network). A DID proved this way can be made the person’s preferred DID. Changing the choice changes the identifier, which must then be enrolled again. - Not usable with it: the Digital Credentials API (it is a desktop app),
DID holder binding (its key proofs carry a
jwk), andjwt_vc_jsonorldp_vcpresentations.
Which flows each wallet supports here
| Flow | EUDI Dev Wallet (web) | OID4VC Wallet (native) |
|---|---|---|
| Portal offer, by value | yes (Open in the web wallet) | yes (Open in a wallet on this device) |
| Portal offer, by reference (QR code) | yes | no |
| Deferred issuance | yes | no |
| Wallet sign-in | yes, by the web wallet link | yes, dc+sd-jwt |
| SIOPv2 subject | JWK thumbprint | thumbprint, did:jwk, did:key, did:web |
Preferred DID / did claim |
no (no DID) | yes |
DID holder binding (kid proof) |
no | no |
haip-vci://, haip-vp:// |
yes, with --register |
yes, with --haip |
HAIP
There is no HAIP mode or HAIP switch in iya-sts. The OpenID4VC High Assurance Interoperability Profile is met through defaults and a few settings, and “HAIP” in a setting’s description means that value is what the profile asks for.
Already the default, with nothing to set:
- The wallet sign-in’s request is signed under
x509_hash(oid4vp.signInClientIdPrefix) and answered encrypted withdirect_post.jwt(oid4vp.signInResponseMode); a plaintext answer is refused (STS-VC-0096). - The encrypted answer may use an ECDH-ES P-256 key, offered beside the X-Wing
key (
oid4vp.responseEncryptionKeyAlgs). - The x509 request certificate is ES256 (
oid4vp.x509SigningAlgorithm). - A credential and its Status List Token carry their chain in
x5c, without the Root (oid4vci.credentialCertificateHeader). - A wallet that asks for an encrypted credential response may give an EC key (ECDH-ES on P-256, P-384 or P-521) as well as an RSA one.
What you set:
| To | Set |
|---|---|
Hand offers over under haip-vci:// |
oid4vci.walletInvocation=haip-vci |
Hand presentation requests (sign-in link, QR code, a verification’s requestUri) over under haip-vp:// |
oid4vp.walletInvocation=haip-vp (an ID Token alone stays siopv2://) |
| Sign credentials with ES256 | oid4vci.credentialSigningAlgorithm=ES256 (RS256 is the default) |
Accept Wallet Attestation (attest_jwt_client_auth) at the token endpoint |
oauth2.clientAttestationTrustAnchors, and under a FAPI 2.0 profile oauth2.fapiAllowClientAttestation — see Attestation-based client authentication |
| Accept or require key attestations | oid4vci.keyAttestationTrustedCertificates, oid4vci.keyAttestationRequired |
| Make an administrator’s verification HAIP-shaped | client_id_prefix: "x509_hash" and response_mode: "direct_post.jwt" on the start (its default prefix is pre-registered, oid4vp.clientIdPrefix) |
The OpenID Foundation’s HAIP issuer test plan runs against a realm with
oauth2.fapi=2-security, Wallet Attestation trusted and allowed under FAPI
2.0, and x5c (see Conformance suites).
Not covered: mso_mdoc is neither issued nor verified, and the Digital
Credentials API’s unsigned and multi-signed request forms and
transaction_data are not implemented.
Settings
The tables below are the settings most deployments touch. Every
oid4vci.* and oid4vp.* setting, with its environment variable, is on
OpenID4VCI and
OpenID4VP; the live source is the console’s tabs
and GET /admin-api/config. All of these can be changed while the service
runs, per realm, except the two marked restart.
Issuer:
| Setting | Environment variable | Default | What it does |
|---|---|---|---|
oid4vci.walletUrl |
OID4VCI_WALLET_URL |
(empty) | A web wallet, offered beside the wallet-app link |
oid4vci.walletIssuancePath |
OID4VCI_WALLET_ISSUANCE_PATH |
(empty) | The page under the web wallet an offer goes to |
oid4vci.walletInvocation |
OID4VCI_WALLET_INVOCATION |
openid-credential-offer |
haip-vci for haip-vci:// |
oid4vci.offerTtlS |
OID4VCI_OFFER_TTL_S |
600 |
How long an offer and its code can be used |
oid4vci.txCodeLength |
OID4VCI_TX_CODE_LENGTH |
5 |
Digits in the Transaction Code |
oid4vci.txCodeMaxAttempts |
OID4VCI_TX_CODE_MAX_ATTEMPTS |
5 |
Product mode: wrong codes before the offer is spent |
oid4vci.batchSize |
OID4VCI_BATCH_SIZE |
4 |
Proofs, and so copies, per request |
oid4vci.credentialLifetimeS |
OID4VCI_CREDENTIAL_LIFETIME_S |
2592000 |
Credential validity (thirty days) |
oid4vci.credentialSigningAlgorithm |
OID4VCI_CREDENTIAL_SIGNING_ALGORITHM |
RS256 |
Signs dc+sd-jwt, jwt_vc_json and the status lists |
oid4vci.credentialCertificateHeader |
OID4VCI_CREDENTIAL_CERTIFICATE_HEADER |
x5c |
How a credential carries its chain |
oid4vci.didHolderBinding |
OID4VCI_DID_HOLDER_BINDING |
(empty) | Configurations bound to a proved did:jwk or did:key |
oid4vci.keyAttestationTrustedCertificates |
OID4VCI_KEY_ATTESTATION_TRUSTED_CERTIFICATES |
(empty) | Wallet providers whose key attestations are believed |
oid4vci.keyAttestationRequired |
OID4VCI_KEY_ATTESTATION_REQUIRED |
false |
Require a key attestation |
oid4vci.statusListTtlS |
OID4VCI_STATUS_LIST_TTL_S |
300 |
How long a verifier may keep a status list |
oid4vci.sdJwtIssuerDid, oid4vci.ldpVcIssuerDid |
OID4VCI_SD_JWT_ISSUER_DID, OID4VCI_LDP_VC_ISSUER_DID |
false |
Restart. Name the plain configurations’ issuer by did:web |
Verifier and sign-in:
| Setting | Environment variable | Default | What it does |
|---|---|---|---|
oid4vp.signIn |
OID4VP_SIGN_IN |
true |
Offer Sign in with a wallet |
oid4vp.signInSelfIssued |
OID4VP_SIGN_IN_SELF_ISSUED |
false |
Offer Sign in with a self-issued ID and the portal enrolment |
oid4vp.signInFormats |
OID4VP_SIGN_IN_FORMATS |
dc+sd-jwt,jwt_vc_json,ldp_vc |
Formats a sign-in asks for |
oid4vp.signInClientIdPrefix |
OID4VP_SIGN_IN_CLIENT_ID_PREFIX |
x509_hash |
How a sign-in’s request names the Verifier |
oid4vp.signInResponseMode |
OID4VP_SIGN_IN_RESPONSE_MODE |
direct_post.jwt |
Encrypted answer, or direct_post |
oid4vp.signInDcApiResponseMode |
OID4VP_SIGN_IN_DC_API_RESPONSE_MODE |
dc_api.jwt |
Encrypted Digital Credentials API answer, or dc_api |
oid4vp.responseEncryptionKeyAlgs |
OID4VP_RESPONSE_ENCRYPTION_KEY_ALGS |
HPKE-10-KE,ECDH-ES |
Keys an encrypted answer may use |
oid4vp.signInCrossDevice |
OID4VP_SIGN_IN_CROSS_DEVICE |
false |
Offer the relayable QR code |
oid4vp.signInTtlS |
OID4VP_SIGN_IN_TTL_S |
300 |
How long a sign-in waits |
oid4vp.walletUrl |
OID4VP_WALLET_URL |
oid4vci.walletUrl |
A web wallet for presentations |
oid4vp.walletPresentationPath |
OID4VP_WALLET_PRESENTATION_PATH |
(empty) | The page under it a request goes to |
oid4vp.walletInvocation |
OID4VP_WALLET_INVOCATION |
openid4vp |
haip-vp for haip-vp:// |
oid4vp.allowedWalletUrls |
OID4VP_ALLOWED_WALLET_URLS |
(empty) | Product mode: other wallets a start may name |
oid4vp.clientIdPrefix |
OID4VP_CLIENT_ID_PREFIX |
pre-registered |
How an administrator’s signed request names the Verifier |
oid4vp.clientId |
OID4VP_CLIENT_ID |
sts-verifier |
The pre-registered Client Identifier |
oid4vp.x509DnsName |
OID4VP_X509_DNS_NAME |
(empty) | The x509_san_dns name; must be the Response URI’s host |
oid4vp.trustedIssuerCertificates |
OID4VP_TRUSTED_ISSUER_CERTIFICATES |
(empty) | Other issuers the Verifier accepts |
oid4vp.requireStatusReference |
OID4VP_REQUIRE_STATUS_REFERENCE |
all |
Whether a credential must name its status |
oid4vp.siopIdTokenMaxAgeS |
OID4VP_SIOP_ID_TOKEN_MAX_AGE_S |
300 |
How old a self-issued ID Token may be |
Elsewhere:
| Setting | Environment variable | Default | What it does |
|---|---|---|---|
oauth2.didClaim |
STS_OAUTH2_DID_CLAIM |
false |
Release the preferred DID as the did claim |
vcApi.enabled |
STS_VC_API_ENABLED |
false |
Serve the W3C VC API |
global.publicBaseUrl |
STS_PUBLIC_BASE_URL |
(empty) | The address clients use (https://idp.example.com); fixes the DIDs, the issuer identifier and the x509_san_dns name |
Endpoints
The authoritative list is /admin/sts-metadata, read from the running router
(see Endpoints). In a trust realm the issuer’s and Verifier’s
paths take /realm/<id> in front (/realm/acme/oid4vci/credential), the two
.well-known issuer documents take it after
(/.well-known/openid-credential-issuer/realm/acme), and the realm’s DID
document is /realm/<id>/did.json.
| Path | What it is |
|---|---|
GET /.well-known/openid-credential-issuer |
Credential Issuer Metadata |
GET /.well-known/jwt-vc-issuer |
SD-JWT VC issuer key resolution |
GET /oid4vci/credential-offer/{id} |
An offer passed by reference |
POST /oauth2/token |
The pre-authorized code grant, and every other grant |
POST /oid4vci/nonce, /oid4vci/credential, /oid4vci/deferred_credential, /oid4vci/notification |
The issuer’s endpoints |
GET /oid4vci/status-lists/1, /oid4vci/status-lists, /oid4vci/status-lists/bitstring/{revocation,suspension} |
Token Status List, its aggregation, the Bitstring Status Lists |
GET /oid4vci/trusted-list |
The issuer’s signed list of trusted entities (ETSI TS 119 602) |
GET /oid4vp/request/{id}, POST /oid4vp/response |
A signed request by reference, and the Response URI |
GET /oid4vp/verifier-certificate |
The x509 Client Identifiers, the chain and trust_anchor_pem |
GET /.well-known/did.json, /did.json, /.well-known/did-configuration.json |
The realm’s DID document and domain linkage |
GET /applications/{id}/did.json |
An application’s DID document |
GET /pki/ca/service/root.cer |
The service Root (DER) |
/authn/wallet, /authn/wallet/wait, /authn-api/wallet* |
The wallet sign-in and SIOPv2 pages and their API |
/portal/wallet, /portal/self-issued, /portal/self-issued/did |
The portal pages |
/account-api/credentials/{offerable,offers,remove}, /account-api/self-issued, /account-api/self-issued/did |
Their account API |
Error codes you will meet
Every refusal is recorded under a code on the audit row and in the log. The
code is never sent to the wallet or the browser; the person sees a sentence,
and the wallet sees the specification’s error (invalid_proof,
invalid_grant, invalid_token). Look the code up in
Error codes. The ones that come up most:
| Code | What happened |
|---|---|
STS-PORTAL-0589 |
An offer named a configuration this issuer does not offer |
STS-PORTAL-0590 |
An offer was refused because the portal’s sign-on session is no longer known; sign in again |
STS-PORTAL-0591 |
A Remove named a credential that is not the person’s |
STS-OAUTH-0938 |
A pre-authorized code was redeemed for a person with no directory entry |
STS-VC-0051 |
The cluster store could not be asked about a single-use value, so the request was refused |
STS-VC-0053 |
/authn/wallet was reached with no pending sign-in; start from the application |
STS-VC-0058 |
A presentation verified but was signed by a trusted foreign issuer, so it signs nobody in |
STS-VC-0059 |
A presentation verified but this realm has no record of issuing it to anybody |
STS-VC-0061 |
A sign-in’s presentation did not verify |
STS-VC-0072 |
A presented credential is revoked or suspended, or its status could not be established |
STS-VC-0088 |
A presented credential names no status, and oid4vp.requireStatusReference requires one |
STS-VC-0096 |
An answer was not in the response mode asked for, for example plaintext to a direct_post.jwt request |
STS-VC-0093 |
A SIOPv2 enrolment came from a browser with no sign-on session, or for somebody else |
STS-VC-0094 |
A proved key was not enrolled: somebody else holds it, or the person already has ten |
STS-VC-0126 |
A did:web ID Token was signed by a key its enrolment did not pin; enrol it again |
STS-VC-0128 |
A did:web document could not be fetched for its enrolment |
STS-VC-0142–STS-VC-0150 |
A credential bound to the person’s DID was refused (see Bind credentials) |
STS-PORTAL-0615, STS-ADMIN-0872 |
A subject could not be made preferred (not proved, or not a DID) |
STS-ADMIN-0871 |
An administrator’s did:web enrolment found no key to pin |
STS-VC-0033 |
A wallet URL that is not configured or allowed was named, in product mode |
STS-VC-0123 |
A verification start’s redirect_uri was not allowed |
Limits and what is not supported
mso_mdocis neither issued nor verified.- DIF Presentation Exchange is not supported; requests are DCQL.
- The five credential configurations are fixed. Only their claims, signing algorithm and binding are configurable.
- No Management API operation makes a pre-authorized offer for someone else.
- No protocol lets this service delete a credential from a wallet. Revoking it is what Remove does.
- Holder binding to a
did:webis not offered, and adid:webhosted by the realm for a person is not built (#775). - The
didclaim is released in ID Tokens and UserInfo only, never in a SAML assertion. - SIOPv2: this service is only the relying party. The
fragmentresponse mode and dynamic discovery of a Self-Issued OP are not offered. - The Digital Credentials API: the unsigned and multi-signed request forms
and
transaction_dataare not implemented. - Historical status lists (
?time=) answer501. - Post-quantum: a trusted issuer certificate or a key attestation certificate with a post-quantum key cannot be read, and the x509 request certificate cannot be post-quantum. Credentials can be signed with ML-DSA, SLH-DSA or a composite, which are drafts many wallets do not accept.
- The sign-in register is bounded (
oid4vp.signInRegisterMaxEntries, 10000 per realm). Past it the oldest row is dropped, and that credential no longer signs anybody in.
Departures from the specifications
In development mode the credential endpoints read an access token without verifying it, and wrong Transaction Codes are not counted. Credentials are signed RS256 unless you choose otherwise, key attestation is not required, and the Verifier refuses a credential that names no status although the status reference is optional in SD-JWT VC. Each is listed, with the setting that changes it, under OpenID4VCI, OpenID4VP and verifiable credentials.
Related
- OpenID4VCI and status lists and Configuring OpenID4VCI
- OpenID4VP, wallet sign-in and DIDs and Configuring OpenID4VP and wallet sign-in
- W3C VC API
- Authentication, for the sign-in screen and second factors
- Configuring OpenID Connect flows,
for the
didclaim - Account API and User-facing pages
- PKI and TLS, for the service Root
- Trust realms
- What is not checked