This page is the reference for how an IXO credential is stored in the person's Vault, their end-to-end encrypted room on IXO Matrix: the two Matrix event types, the exact record format, the index and its state keys, how a credential is de-duplicated, and how a wallet saves, lists and reads credentials. Read it if you build a wallet that writes to the Vault, read credentials from it in another IXO app, or need to know exactly what is encrypted and what is not. Every IXO app that stores credentials uses this format, so a credential saved by one is seen once by all of them.
This page describes the Auth Hub on its main branch as of 9 November 2026 and @ixo/credentials-kit 0.1.1. Values in the examples are fake. The kit functions named here (credentialRecordFromSdJwt, credentialCid, stateKeyForVct) are the ones the Hub itself uses.
What is stored where
| What | Matrix event | Encrypted | Holds |
|---|---|---|---|
| One credential | Timeline event of type ixo.credential, sent as m.room.encrypted with Megolm (m.megolm.v1.aes-sha2) | Yes | The whole record: the SD-JWT with all its disclosures, and every claim with its value |
| The index of one credential type | State event of type ixo.credential.index, one per state key | No | One pointer per stored credential: event id, time stored, CID, issuer DID, holder DID, type and format. Never a claim |
The pattern is the "indexed encrypted blob" scheme of the IXO Portal (lib/matrix/indexedEventStore.ts storeIndexedBlob and lib/kyc/kycVault.ts), which the Auth Hub ported through Self's web app. The body goes in an encrypted timeline event; a plaintext state event, which Matrix does not encrypt, holds only pointers, so a wallet can list what a person holds without decrypting anything and decrypt only the credentials it needs.
In the Auth Hub, the only writer is the person's credentials device: one Durable Object per person, registered as a Matrix device of that person named "IXO credentials wallet". It runs that person's Vault operations one at a time, so its own index updates never race each other. When the Hub may set it up for the first time is described in A person's first save.
The credential event
The content of the ixo.credential event, before encryption, has exactly one member, credential, whose value is the record as a JSON string (stringified so that Matrix's rules on numbers in event content never apply to it):
{
"type": "ixo.credential",
"content": {
"credential": "{\"recordType\":\"SdJwtCredentialRecord\",\"format\":\"dc+sd-jwt\",…}"
}
}What the homeserver stores and other clients receive is the encrypted form, with no type, claim or issuer visible:
{
"type": "m.room.encrypted",
"sender": "@did-ixo-ixo1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn4uq0hv:HOMESERVER",
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"session_id": "MEGOLM_SESSION_ID",
"ciphertext": "MEGOLM_CIPHERTEXT"
}
}The device puts the event's room key in the person's key backup (m.megolm_backup.v1.curve25519-aes-sha2) and confirms it is there before it sends the event. If the key cannot be confirmed in the backup, nothing is sent: an event whose key existed only on one device would be lost with that device. The room key is shared with every device of every joined member of the Vault room; see the warning under The credentials device.
The record
The decrypted string parses to an SdJwtCredentialRecord. The kit builds it from the issued SD-JWT with credentialRecordFromSdJwt(sdjwt, { holderDid }), after the wallet has verified the credential (the function only parses; it does not check the signature).
{
"recordType": "SdJwtCredentialRecord",
"format": "dc+sd-jwt",
"credential": "ISSUER_SIGNED_JWT~DISCLOSURE_1~DISCLOSURE_2~",
"issuerDid": "did:web:test-issuer.dev.credentials.ixo.earth",
"holderDid": "did:ixo:ixo1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn4uq0hv",
"vct": "https://test-issuer.dev.credentials.ixo.earth/types/TestCredential",
"issuedAt": "2026-11-09T08:15:00.000Z",
"expiresAt": "2026-12-09T08:15:00.000Z",
"claims": {
"age_over_18": { "value": true, "sd": "allowed" },
"city": { "value": "Tbilisi", "sd": "allowed" }
},
"holderBinding": true
}| Field | Type | Value |
|---|---|---|
recordType | string | Always SdJwtCredentialRecord |
format | string | Always dc+sd-jwt |
credential | string | The SD-JWT as issued, in compact form: the issuer-signed JWT and every disclosure, without a Key Binding JWT (at most 32,768 characters) |
issuerDid | string | The credential's iss |
issuerName | string, optional | A display name for the issuer. The kit adds it only when asked; the Auth Hub never sets it (names come from the catalog) |
holderDid | string | The credential's sub, the person's did:ixo |
vct | string | The credential type, exactly as in the credential |
issuedAt | string | iat as an ISO 8601 date and time in UTC |
expiresAt | string, optional | exp as an ISO 8601 date and time in UTC, when the credential has one |
claims | object | Every top-level claim by name, as { value, sd }. sd is never (a plaintext claim in the signed payload), allowed (a top-level disclosure) or always. The kit writes only never and allowed; a nested disclosure stays inside its parent's value |
holderBinding | boolean | Whether the credential carries cnf |
The registered and structural claims (iss, sub, iat, nbf, exp, vct, cnf, status, _sd, _sd_alg, jti, aud) are not copied into claims; they stay in the SD-JWT.
The Hub refuses to store or return a record that does not have exactly this shape: a wrong recordType or format, a missing or oversized field, a DID that does not start with did:, a claim without value or with another sd, or any unknown top-level field. An extra field would change the record's CID, and the same credential stored by another app would no longer de-duplicate against it.
The index
Each credential type has one ixo.credential.index state event. Its content is a list of entries, one per stored credential, in the order they were added:
{
"type": "ixo.credential.index",
"state_key": "vct-c23cc7d79ee3094d1cd07eb8272d0235",
"content": {
"entries": [
{
"eventId": "$CREDENTIAL_EVENT_ID",
"storedAt": "2026-11-09T08:15:04.112Z",
"cid": "sha256:14cee13c847417bd7f12ff48f9e9bdcfba36adb83a1a363e0e3f0fb9889c3f8d",
"issuerDid": "did:web:test-issuer.dev.credentials.ixo.earth",
"holderDid": "did:ixo:ixo1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn4uq0hv",
"credentialType": "https://test-issuer.dev.credentials.ixo.earth/types/TestCredential",
"format": "dc+sd-jwt"
}
]
}
}| Entry field | Value |
|---|---|
eventId | The ixo.credential timeline event that holds the record |
storedAt | When the entry was written, ISO 8601 in UTC |
cid | The record's CID (see The CID) |
issuerDid, holderDid | From the record |
credentialType | The record's vct |
format | dc+sd-jwt |
The Hub reads eventId and storedAt and skips an entry without them; it treats the other fields as optional, because other apps may write entries with fewer or more fields. A writer keeps every existing entry exactly as it found it, including fields it does not know.
State keys
The state key of a type's index is stateKeyForVct(vct) from @ixo/credentials-kit/profile. It maps the full vct exactly, with no change of case or any other rewriting:
vct | State key |
|---|---|
https://credentials.ixo.world/types/IdentityLevel1 (Self's type) | identitylevel1 |
https://github.com/ixofoundation/ns/blob/main/protocol/credentials/v1/KYCAMLLevel1.json (the portal's KYC type) | kycamllevel1 |
Any other vct | vct- and the first 32 hex characters of the SHA-256 of the vct in UTF-8 |
For example, the devnet test type https://test-issuer.dev.credentials.ixo.earth/types/TestCredential is stored under vct-c23cc7d79ee3094d1cd07eb8272d0235. The two known types keep the slots the portal already uses. Every other type gets its own slot from a hash of the whole string, so two different vct values never share an index, and a look-alike type from another issuer (https://credentials.ixo.world/types/identitylevel1, lower case, maps to vct-ec2aeb45a0802f0e01b072a739007bbf) cannot land in a known type's slot. A state key is 1 to 128 characters of A-Z a-z 0-9 . _ -.
All credentials of one type share one index, whoever issued them: two IdentityLevel1 credentials from two issuers are two entries under identitylevel1, told apart by issuerDid.
The CID
The CID is the record's content identifier and the de-duplication key of the index:
cid = "sha256:" + lowercase_hex( SHA-256( UTF-8( JSON.stringify( canonicalize(record) ) ) ) )canonicalize sorts the keys of every object recursively (JavaScript's default string sort) and keeps the order of arrays; JSON.stringify then writes the result with no whitespace. The kit's credentialCid(record) computes it, and it is byte-identical to the Auth Hub's, the IXO Portal's (computeCredentialCid in lib/kyc/kycVault.ts) and JAMBO's (utils/matrixCredential.ts), so the same record gets the same CID in every app.
This is not a multiformats CID. It is a plain SHA-256 digest of the canonical JSON with a sha256: prefix: no multihash, no multicodec, no multibase and no CID version. It is also not RFC 8785 (JSON Canonicalization Scheme) output; it is IXO's own canonical form, kept identical across the apps. (The UCAN tokens the Credentials worker accepts are a separate case: each UCAN block there carries a real CIDv1, dag-cbor with sha2-256.)
What changes the CID and what does not:
- The order of keys does not: records with the same members in another order have the same CID.
- Any difference in content does: an added
issuerName, anotherstoredAt-like field, a claim with anothersdvalue. That is why the Hub refuses unknown fields. - A different credential always has a different CID, even of the same type, from the same issuer, for the same person: a newly issued SD-JWT has a new signature and new disclosure salts.
The Hub has a second, narrower de-duplication for the same credential stored under a different record shape by another app (for example with issuerName added): before it writes, it compares the SHA-256 of the credential's issuer-signed JWT with the credentials it has already checked in the Vault, and stores nothing when one matches.
Saving a credential
The Hub saves a credential only after the kit has verified it (issuer signature, iss, sub = the person, cnf = the person's key, type, validity) and its status list said it is not revoked. Then the device:
- Checks the room. The room must be encrypted with Megolm (
m.room.encryption), the person must be joined, and no one else may be a member except service accounts on the person's own homeserver (such as the rooms bot). Another person's account (@did-ixo-…) or any account on another homeserver stops the save withvault_needs_attention. Nothing is written. - Computes the CID of the record and reads the type's index from the server, using only an index the person sent (see Reading and listing).
- De-duplicates. If an entry with the same
cidis there, it writes nothing and answers with that entry's event id and time (duplicate: true). - Checks the limits before sending anything: the event must fit (see Limits), and the index must be able to take one more entry.
- Sends the encrypted
ixo.credentialevent after sharing the room key and confirming it is in the key backup. The Matrix transaction id isixocred-and the first 32 hex characters of the CID, so a send repeated after a lost answer returns the event the homeserver already has instead of a second copy. - Re-reads the index right before writing it, so entries another app added in the meantime are kept, and stops as a duplicate if one of them is this credential.
- Writes the index with every existing entry untouched plus the new one at the end.
- Reads the index once more. Matrix state has no compare-and-swap, so a write by another app between steps 6 and 7 would be lost. If an entry that should be there is missing, the device merges and writes the index again.
Before step 5 the Hub records the credential, sealed, in its own database, so a retry after a failure stores the same record again and never redeems the offer a second time. The audit row of the save records the CID and whether it was a duplicate.
Automatic saves and a full index
Credentials the Hub saves automatically (see Automatic saving) may use only a small share of a type's index: at most 10 entries of that index, and at most 3 from one issuer. Over either share, the automatic save fails with nothing written, the activity log says so, and the person can still save the credential by hand.
A save the person makes is never refused because of those shares. When the index is too full to take a manual save, the device takes the oldest automatic entries out of the index, one at a time, until the new entry fits. Their ixo.credential events stay in the room; only the pointers go. Entries written by other apps and the person's own manual saves are never taken out. If the index still cannot take the entry, the save fails with vault_full and nothing is written.
Reading and listing
Listing reads the room's state and returns every entry of every ixo.credential.index event. It decrypts nothing and needs no room key. The Hub uses only index events sent by the person's Matrix user (other members of the room may have power to send state events; their indexes are ignored and counted in the log). The portal, JAMBO and the person's other apps sign in as the person, so their indexes count. When another member replaced the person's index, the device builds on the person's last own version of it, found through unsigned.replaces_state (at most 3 events back).
From that list, the Hub's wallet shows the entries with format dc+sd-jwt and a string credentialType and issuerDid, at most 50 per type and issuer and 500 in all. Other records in the index (see Compatibility) are left alone and not shown.
Reading one credential fetches its event by eventId and decrypts it, with the room key from the device's own store or, failing that, the one session from the key backup. The Hub accepts the event only when:
- it is
m.room.encryptedwith Megolm, in the Vault room, sent by the person's Matrix user; - after decryption, the sender is the person, the room key was not forwarded by another user, and the key was not made by another user's device (a key from a device the credentials device does not know, such as one of the person's devices that has since been removed, is accepted and logged: it came from the person's own key backup);
- the decrypted type is
ixo.credentialandcredentialparses to a valid record.
Anything else is treated as not found. A credential whose room key is gone (for example after another client reset the key backup) is skipped and counted, and never stops the others from being read.
Checking a listed credential: before the Hub shows a credential as valid or uses it for a presentation, it verifies it with the kit: the issuer signature, iss and vct equal to the record's, sub equal to the person, cnf equal to the person's key, and the status list. A genuine credential that is revoked, expired or not yet valid is shown as such; one that fails is shown as "could not be checked" and is never shared. To answer a request, the Hub filters index entries by type and issuer and decrypts only those candidates (at most 20 in one read).
Re-verification and old credentials
A new credential never replaces an old one. When a person verifies again, or an issuer issues a new credential of the same type, the new credential has a new CID and is added as a new entry under the same state key. The earlier entry and its event stay. Expired and revoked credentials stay in the Vault too; the wallet shows each one's state from its exp and from the issuer's status list. The Hub never redacts an ixo.credential event. The only entries it ever takes out of an index are its own automatic saves, as described under Automatic saves and a full index.
Limits
| Limit | Value |
|---|---|
One ixo.credential event | The plaintext (type, content and room id as JSON) at most 44,000 bytes, so the encrypted event stays under Matrix's 64 KiB event limit; otherwise vault_full |
| The SD-JWT in a record | At most 32,768 characters |
One ixo.credential.index content | At most 60,000 bytes, about 200 entries per type; otherwise vault_full (the Hub answers not_supported, "the Vault index for the type is full") |
| Automatic saves per type index | 10 entries, at most 3 per issuer |
| Credentials one read decrypts | 20 |
Who can see what
The index is plaintext by design, so listing never needs a key. The cost is that anyone who can read the room's state sees, for every stored credential:
| Visible in the clear (the index) | Encrypted (the event) |
|---|---|
The credential type (credentialType, and the state key), the issuer DID, the holder DID, the format, when it was stored, the CID and the event id | The SD-JWT and every claim value, for example a name, a birth date or age_over_18 |
Who that is:
- The homeserver sees the index and the size and timing of every event. It cannot decrypt the credentials.
- Every joined member of the Vault room sees the index, and, by the owner's decision, every member's devices also receive the room keys and can decrypt the credentials (see The credentials device). The Hub refuses to write while any other person or any account from another homeserver is a member.
- The Auth Hub, for people whose keys it holds, decrypts credentials in memory when it stores, checks or presents one, and never logs claims. Its own credentials database caches the index (types, issuers, dates, CIDs), never a claim.
Never put personal data in an index entry. A wallet that writes its own entries must keep to the fields above.
Compatibility with the portal and other apps
The IXO Portal, JAMBO, Self's web app (in its earlier flow) and the Auth Hub all write the same scheme in the same room: the encrypted ixo.credential event with { "credential": "<JSON string>" }, the ixo.credential.index state event, and the same CID.
- The portal's KYC records are written under the state key the KYC service names the credential by,
kycamllevel1for KYC Level 1, andstateKeyForVctmaps the portal's KYC type to that same slot. A portal record may be a legacy W3C Verifiable Credential rather than anSdJwtCredentialRecord; its index entry then carriesformat: "vc+ld-proof". The Hub leaves them untouched: it keeps their entries when it rewrites an index, does not list them in its wallet and does not read them. - The portal's personal-data records (
ixo.piievents andixo.pii.indexstate events in the same room) are a separate scheme; the Hub never reads or writes them. - Entries from other writers (the portal, JAMBO, Self's earlier browser flow, a self-sovereign wallet) are kept exactly as they are whenever the Hub writes an index, and a credential one app stored is a duplicate for the others when the records are identical.
- Self no longer writes to the Vault from the browser (owner's decision of 9 November 2026). When Self goes live, a person gets their Self credential only through the Auth Hub, which stores it as described on this page.
Writing from your own wallet
A self-sovereign wallet (see The self-sovereign path) must write exactly the same shapes, or the other apps will not see its credentials or will store them twice:
- Verify the credential first (
redeemOfferdoes, throughverifyIssuedCredential), then build the record withcredentialRecordFromSdJwt(credential, { holderDid }), adding no fields. - Refuse a room without Megolm encryption. Never reset the person's encryption (see the warning on Hold and share credentials).
- Compute
credentialCid(record)andstateKeyForVct(record.vct), read that index fresh from the server (only an index the person sent), and stop if an entry has the samecid. - Put the room key in the key backup, then send the encrypted
ixo.credentialevent. - Write the index with every existing entry untouched plus
{ eventId, storedAt, cid, issuerDid, holderDid, credentialType, format }, re-reading it just before and checking it just after, as the Hub does.
import {
credentialCid,
credentialRecordFromSdJwt,
} from '@ixo/credentials-kit/sdjwt';
import { stateKeyForVct } from '@ixo/credentials-kit/profile';
const record = credentialRecordFromSdJwt(credential, { holderDid: PERSON_DID });
const cid = credentialCid(record); // 'sha256:…'
const stateKey = stateKeyForVct(record.vct); // 'identitylevel1', 'kycamllevel1' or 'vct-…'
const content = { credential: JSON.stringify(record) }; // the ixo.credential contentRelated pages
- Hold and share credentials: the Auth Hub wallet, its credentials device and the self-sovereign path
- Standards and conformance: SD-JWT VC and the other specifications
- Status lists and the catalog: how a stored credential's revocation is checked
- IXO Matrix