Storage in the Vault

How IXO credentials are stored in the person's Matrix Vault: the encrypted ixo.credential event, the SdJwtCredentialRecord, the plaintext ixo.credential.index state event and its state keys, the CID used to de-duplicate, the save and read flows, and what other room members and the homeserver can see.

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.

What is stored where

WhatMatrix eventEncryptedHolds
One credentialTimeline event of type ixo.credential, sent as m.room.encrypted with Megolm (m.megolm.v1.aes-sha2)YesThe whole record: the SD-JWT with all its disclosures, and every claim with its value
The index of one credential typeState event of type ixo.credential.index, one per state keyNoOne 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
}
FieldTypeValue
recordTypestringAlways SdJwtCredentialRecord
formatstringAlways dc+sd-jwt
credentialstringThe SD-JWT as issued, in compact form: the issuer-signed JWT and every disclosure, without a Key Binding JWT (at most 32,768 characters)
issuerDidstringThe credential's iss
issuerNamestring, optionalA display name for the issuer. The kit adds it only when asked; the Auth Hub never sets it (names come from the catalog)
holderDidstringThe credential's sub, the person's did:ixo
vctstringThe credential type, exactly as in the credential
issuedAtstringiat as an ISO 8601 date and time in UTC
expiresAtstring, optionalexp as an ISO 8601 date and time in UTC, when the credential has one
claimsobjectEvery 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
holderBindingbooleanWhether 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 fieldValue
eventIdThe ixo.credential timeline event that holds the record
storedAtWhen the entry was written, ISO 8601 in UTC
cidThe record's CID (see The CID)
issuerDid, holderDidFrom the record
credentialTypeThe record's vct
formatdc+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:

vctState 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 vctvct- 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.

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, another storedAt-like field, a claim with another sd value. 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:

  1. 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 with vault_needs_attention. Nothing is written.
  2. 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).
  3. De-duplicates. If an entry with the same cid is there, it writes nothing and answers with that entry's event id and time (duplicate: true).
  4. Checks the limits before sending anything: the event must fit (see Limits), and the index must be able to take one more entry.
  5. Sends the encrypted ixo.credential event after sharing the room key and confirming it is in the key backup. The Matrix transaction id is ixocred- 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.
  6. 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.
  7. Writes the index with every existing entry untouched plus the new one at the end.
  8. 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.encrypted with 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.credential and credential parses 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

LimitValue
One ixo.credential eventThe 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 recordAt most 32,768 characters
One ixo.credential.index contentAt 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 index10 entries, at most 3 per issuer
Credentials one read decrypts20

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 idThe 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, kycamllevel1 for KYC Level 1, and stateKeyForVct maps the portal's KYC type to that same slot. A portal record may be a legacy W3C Verifiable Credential rather than an SdJwtCredentialRecord; its index entry then carries format: "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.pii events and ixo.pii.index state 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:

  1. Verify the credential first (redeemOffer does, through verifyIssuedCredential), then build the record with credentialRecordFromSdJwt(credential, { holderDid }), adding no fields.
  2. Refuse a room without Megolm encryption. Never reset the person's encryption (see the warning on Hold and share credentials).
  3. Compute credentialCid(record) and stateKeyForVct(record.vct), read that index fresh from the server (only an index the person sent), and stop if an entry has the same cid.
  4. Put the room key in the key backup, then send the encrypted ixo.credential event.
  5. 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 content