This page explains the wallet side of IXO Credentials: how the Auth Hub's /credentials pages collect, store and share credentials for a person, how a person blocks a sender, and how a person or team runs their own wallet (a self-sovereign wallet) with @ixo/credentials-kit, the person's own keys and the standard links, with no Hub involved. Read it if you build a wallet, work on an app that holds user keys, or need to know what the Hub does on a person's behalf.
The Auth Hub wallet is live and switched on on all three networks: https://dev.auth.ixo.earth/credentials (devnet), https://test.auth.ixo.earth/credentials (testnet) and https://auth.ixo.earth/credentials (mainnet). On these pages HUB_URL is the Hub of your network and CREDENTIALS_WORKER_URL its Credentials worker (see Where it runs). The kit is @ixo/credentials-kit on npm.
Both kinds of wallet use the same Credentials worker endpoints, the same keys and the same Vault format. Issuers and verifiers cannot tell them apart.
What a verifier always sees
Whatever a wallet discloses, every presentation also carries, in the clear: the person's IXO account ID (sub, the same at every service), the holder key (cnf.jwk, also the same everywhere), the status reference (list URL and index), the issuer, the dates, and every claim the issuer did not make selectively disclosable. This is a property of the credential format, not something the wallet chooses. The Hub's sharing screen does not list it; see What a verifier always sees in the overview.
The Auth Hub wallet
What the person sees
| Page | Contents |
|---|---|
HUB_URL/credentials | Needs you (offers and requests waiting, each with a "Review" button; the first three, then "Show N more"), Your credentials, a row to Sharing, and the latest activity |
HUB_URL/credentials/offers/OFFER_ID | The card as it will look, who offers it, what it holds; "Save to my credentials" or "No thanks" |
HUB_URL/credentials/requests/REQUEST_ID | Who is asking (mark and name); why, in the verifier's own words (the request's purpose); the credential used; the details that will leave, with their values; what is not shared, in one line; "Just this time" or "Always for NAME" with its end date; "Share" or "Don't share" |
HUB_URL/credentials/items/CREDENTIAL_ID | What a credential contains, who issued it, when it expires, whether it is revoked, and who it was shared with |
HUB_URL/credentials/sharing | Services always allowed (standing consents, each with its end date and "Stop"), the switch Save new credentials automatically, and Blocked (blocked senders, each with the date and "Unblock") |
HUB_URL/credentials/activity | Every action the Hub took, manual or automatic, in plain words, blocks and unblocks included |
Before declining an offer ("No thanks") or a request ("Don't share") is final, the Hub offers a choice "Also block NAME" (off by default).
Names, logos and card looks come only from the sender's catalog entry: an issuer's name, logo_uri, color and types[].display, a verifier's name, logo_uri and color. They are what the sender says about itself, and the Hub shows them as given, for every sender alike. A sender without an entry gets a neutral mark and card. The Hub never shows a web address from a sender (its url, credential_issuer or any other), not as a link and not as text to copy: a harmful link would be blamed on IXO. It loads logos and card images only through its own media proxy, which takes png, jpeg or webp. A request's purpose is the one piece of text that comes with the request. See What protects a person.
How the Hub keeps up to date
The overview reads only the Hub's own credentials database, kept apart from its account database: a cached copy of the person's pending offers and requests and of their Vault index. The cache is refreshed by the worker's signed webhook, by the page asking with GET /api/credentials/overview?sync=1 (at most once a minute per person; sent when the page opens or the tab becomes visible), and after every action the Hub takes. Webhook work is handed to the person's own Durable Object and runs from its alarm with retries, never in the background of the webhook request.
Automatic saving
Off by default, and never for an issuer the person blocked. The Hub saves an offered credential without asking only when:
- the person turned on Save new credentials automatically (any issuer, by the owner's decision), or
- the offer carries an offer consent: a UCAN delegation signed by the person to the issuer that deposited the offer, ability
credentials/offeronixo:credentials, the offer's type innb.vct, unexpired. The Hub verifies it itself. Self (not live yet) will ask for one when a verification starts.
Automatic saving is capped at 10 credentials per issuer per person per day. Everything else waits for the person. Before storing, the Hub checks the credential with the kit's verifyIssuedCredential (signature, issuer = the DID that deposited the offer, sub = the person, cnf = the person's key, the offered type, validity) and a readable status list that does not say revoked; a credential that fails is not stored and the activity log says so.
Standing consents ("Always for a service")
"Just this time" is selected by default. When the person approves a request with "Always for NAME" chosen and the service accepts the answer, the Hub signs a UCAN delegation from the person to that service's DID: ability credentials/present on ixo:credentials, nb: { vct, claims, issuers }, expiring after CREDENTIALS_CONSENT_DAYS (180 by default). The issuers are pinned: automatic sharing uses only credentials from the issuer(s) recorded when the consent was created. A later request is answered without asking only if it is from the same service, every type and claim is covered, and the consent has not expired or been stopped. Automatic sharing is capped at 20 per consent per day, and each one is logged. The person sees each consent on the Sharing page with its end date and can stop it at any time. Blocking a service stops its standing consents. The service never sees the delegation.
Actions, logs and audit
Saving, sharing, declining, blocking and unblocking are idempotent: repeating a finished action answers with the same result and already: true. Once a presentation may have reached the service it is recorded as sent, with the service's answer. Every action writes a line to the person's activity log and an audit row in the same database transaction. Audit rows record which credential (opaque id, type, issuer) and which claim names were shared, never claim values; opening a stored credential is audited too.
The credentials device
To read and write the Vault without the person's browser, the Hub runs one Durable Object per person: a device of that person on IXO Matrix, named "IXO credentials wallet", visible in the person's device list. Its rules:
- It never resets the person's encryption. It adopts an existing setup only after the recovery passphrase reproduces the secret-storage check value. On an account with no encryption at all it sets up the complete set (secret storage, all cross-signing keys, key backup) so other apps adopt it.
- It stops, and writes nothing, if the setup is half finished (
vault_needs_attention). - It puts each message key in the key backup before sending.
- It keeps no passwords. It stores only its sign-in token, device id, encryption state and backup key, encrypted with a Hub secret.
- It reads credentials only in memory, only when needed.
Room keys go to every joined member of the Vault room. By the owner's decision (confirmed again on 6 Nov 2026), the key of each credential the device writes is shared with every device of every joined member of the person's Vault room: the person's own devices, verified or not, and service accounts in the room such as the rooms bot. The room is the person's backup and those bots must be able to read. The consequence: any device of any member of that room can decrypt the credentials stored there.
The Hub can act as a wallet only for people whose DID signing key and Matrix mnemonic it holds (accounts it created). Everyone else uses a self-sovereign wallet.
For Hub users the Hub is custodial: it holds the person's signing key and sees claims, in memory, whenever it stores or presents a credential. It never logs claims, and every action appears in the person's activity log.
The self-sovereign path
A person, or a team building an app, can run a complete wallet without the Hub: the kit, the person's own keys, the Credentials worker's endpoints and the standard OpenID links. Nothing in the system requires the Hub. The examples/e2e/e2e.mjs script in the ixoworld/ixo-credentials repository (private for now) plays such a wallet end to end against the real worker (everything but the Vault write).
What the wallet needs
| Need | Detail |
|---|---|
| The person's DID | did:ixo:ixo1… |
| A DID signing key | An Ed25519 key listed under authentication on that DID document. It signs UCAN invocations to the worker, issuance proofs and presentations. |
| A Matrix session with encryption | Signed in as the person, able to read and write the Vault room |
| A DID resolver | The kit's createIxoResolver (did:ixo on IXO Blocksync: https://devnet-blocksync-graphql.ixo.earth/graphql, https://testnet-blocksync-graphql.ixo.earth/graphql or https://blocksync-graphql.ixo.earth/graphql) and createWebResolver, joined with composeResolvers |
| The kit | npm install @ixo/credentials-kit@0.1.1 @ixo/ucan (pin an exact version) |
For an Auth Hub user, the Hub's sign-in exchange gives an app these keys when it asks for them: the ucan scope returns the DID signing key's mnemonic, the matrix scope the Matrix mnemonic, user id, room id and homeserver.
Never reset the person's Matrix encryption. Open the existing secret storage with the recovery passphrase and create secret storage, cross-signing or a key backup only when none exists. A reset locks the person out of their stored credentials in every other app, including the Hub.
Steps
-
List offers and requests. Poll
GET /v1/offers?status=pendingandGET /v1/requests?status=pending(answers are{ items, next_cursor }). Each call carries a fresh UCAN invocation signed with the person's key: abilitycredentials/hold, resourceixo:credentials, audience the worker'sdid:web, at most 300 seconds, used once, with the fact{ method: 'GET', path: '/v1/offers' }for that exact request. -
Collect a credential. Read the offer with
GET /v1/offers/OFFER_ID(as the subject you getcredential_offer_uriorcredential_offer, andconsent_tokenwhen the issuer attached a valid consent). Then redeem it:import { redeemOffer, sendNotification } from '@ixo/credentials-kit/oid4vci'; const out = await redeemOffer({ credentialOfferUri: offer.credential_offer_uri, // fetched fresh: a new single-use code signer: personSigner, // kid = the person's key on their DID document resolver, expectedIssuer: offer.issuer, // the DID that deposited the offer fetch, });redeemOfferchecks the credential withverifyIssuedCredential(issuer signature,iss,sub= the person,cnf= the person's key, type, validity, disclosures) and throwsinvalid_credentialinstead of returning one that fails. An offer that needs a transaction code is refused (tx_code_unsupported). -
Store it. Build the Vault record with
credentialRecordFromSdJwt(credential, { holderDid })and its id withcredentialCid(record). Send an encryptedixo.credentialevent, then add an entry to theixo.credential.indexstate event at the keystateKeyForVct(vct), unless an entry with the same CID exists.stateKeyForVctmaps the exactvct: known types keep their slots (identitylevel1,kycamllevel1), any other type getsvct-plus 32 hex characters of its SHA-256. Refuse a room without encryption. -
Close the offer. Send the issuer
credential_acceptedwithsendNotificationwhen it returned anotificationId, thenPOST /v1/offers/OFFER_ID/statuswith{ "status": "accepted" }(ordeclined). -
Answer a request. Read it with
GET /v1/requests/REQUEST_ID, or take anopenid4vp://?client_id=…&request_uri=…link a verifier showed and parse it withparseWalletLink, then fetch the request object yourself (no redirects, a timeout, a size cap). Verify it withverifyAuthorizationRequest(jwt, { resolver, expectedClientId: link.clientId, requireExpiry: true }), show the person who is asking (name and logo from the verifier's catalog entry), why (the request'spurpose), which details will leave with their values, and which will not, find a credential withmatchCredential, build the presentation withcreatePresentation(audience = the request'sclient_id, nonce = its nonce) and send it withcreateAuthorizationResponse. If the person says no, sendaccess_deniedwithsendAuthorizationError, only after they decided. Then set a broker request toansweredordeclined.
For local development only, pass allowHttp: true and allowLocal: true to the kit calls that reach localhost; production passes neither.
What a self-sovereign wallet gets
| Auth Hub wallet | Self-sovereign wallet | |
|---|---|---|
| Offers and requests for the person | Yes | Yes, same endpoints |
| Requests from any OpenID4VP verifier | Through the broker | Through the broker or openid4vp:// links |
| Notice when something arrives | Signed webhook from the worker | None; poll the lists |
| Automatic saving and answering | Built in, off by default | Only if the app builds it, under the same rules |
| Activity log | Built in | The app must keep one |
| Block a sender | "Also block NAME" when declining; Sharing page | POST /v1/blocks as the person; the same list |
If a person uses both, both may act on the same item. The Vault de-duplicates a credential by its CID, and the inbox item ends up closed either way.
Block a sender
A person can block any issuer or verifier. The block is kept at the Credentials worker, so it holds for every wallet the person uses. One list covers issuers and verifiers.
What a block does:
- The worker refuses every new offer and request from that sender for this person with
403 blockedand the message "This person is not accepting offers or requests from you." Nothing is stored and no webhook is sent. - It closes every offer and request from that sender already waiting for the person, as declined.
- A block holds for exactly the DID named. Every DID is its own sender: blocking
did:web:a.example.comdoes not blockdid:web:b.example.com,did:web:example.comordid:web:example.com:user:alice. A sender with many DIDs is many senders, and the person blocks each one. - An offer is not refused for the web address it names. Before saving a credential, the Auth Hub checks the credential's issuer (
iss, the exact DID) against the person's blocks. - Every block, unblock and refused deposit is recorded in the worker's audit trail.
A person can keep at most 500 blocks; one more is refused with 409 too_many_blocks.
In the Auth Hub, the person blocks when declining ("Also block NAME") and unblocks on the Sharing page. After a block the Hub removes that sender's waiting items at once, stops the standing consents for that service, and never saves automatically from that issuer.
A self-sovereign wallet calls the worker as the person, with a credentials/hold invocation bound to the request, as in the steps above:
| Call | Body | Answer |
|---|---|---|
POST /v1/blocks | { "sender": "SENDER_DID" } | 200 { sender, created_at, closed }; closed is how many waiting items it closed |
POST /v1/blocks/remove | { "sender": "SENDER_DID" } | 200 { sender, removed }; removed is false when there was no block |
GET /v1/blocks?cursor=&limit= | — | { items: [{ sender, created_at }], next_cursor } |
Both writes are idempotent. The kit has helpers for them next to its other holder calls: blockSender, unblockSender and listBlocks. Errors are in the API reference.