# 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](/articles/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.

<Info>
  This page describes the Auth Hub on its `main` branch as of 9 November 2026 and [`@ixo/credentials-kit`](https://www.npmjs.com/package/@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.
</Info>

## 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](/guides/dev/credentials/hold-and-share#automatic-saving).

## 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):

```json
{
  "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:

```json
{
  "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](/guides/dev/credentials/hold-and-share#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).

```json
{
  "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:

```json
{
  "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](#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:

```text
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.

<Note>
  **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`.)
</Note>

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](#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](#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.

```mermaid
sequenceDiagram
  autonumber
  participant H as Auth Hub
  participant D as Credentials device
  participant M as Homeserver (Vault room)
  H->>H: verifyIssuedCredential + status, then credentialRecordFromSdJwt
  H->>D: store(record, stateKeyForVct(vct))
  D->>M: GET room state (encryption, members, ixo.credential.index)
  D->>D: credentialCid(record); entry with this cid? answer duplicate, stop
  D->>M: share room key, put it in the key backup, confirm
  D->>M: PUT send m.room.encrypted (txn ixocred-CID)
  M-->>D: event_id
  D->>M: GET room state again (keep entries added meanwhile)
  D->>M: PUT state ixo.credential.index / STATE_KEY {entries: [...existing, new]}
  D->>M: GET room state (entry lost to a concurrent write? merge, PUT again)
  D-->>H: {eventId, storedAt, cid, duplicate: false}
```

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](/guides/dev/credentials/hold-and-share#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](#compatibility-with-the-portal-and-other-apps)) 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](/guides/dev/credentials/status-lists-and-catalog). 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](#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](/guides/dev/credentials/hold-and-share#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](/guides/dev/credentials/hold-and-share#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](/guides/dev/credentials/hold-and-share#what-the-wallet-needs)).
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.

```ts
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
```

## Related pages

- [Hold and share credentials](/guides/dev/credentials/hold-and-share): the Auth Hub wallet, its credentials device and the self-sovereign path
- [Standards and conformance](/guides/dev/credentials/standards): SD-JWT VC and the other specifications
- [Status lists and the catalog](/guides/dev/credentials/status-lists-and-catalog): how a stored credential's revocation is checked
- [IXO Matrix](/articles/ixo-matrix)
