IPFS & identity storage.
A SciPHR identity is bigger than what the XRP Ledger will hold inline. The encrypted backup of your master key and the DID document that describes your wallet and the devices bound to it are both larger than the ledger's 256-byte field cap. So the ledger keeps a small pointer, and the full object lives in content-addressed storage on IPFS. The pointer is a hash, so the off-chain object can always be checked against it.
Summary
Two things are too big for the chain: the encrypted envelope
and the DID document. Both are pinned to IPFS and anchored
on-chain by a content address. For the DID, the clean pattern is a single field:
DIDSet URI = ipfs://<did-document-cid>.
Content is addressed by its hash.
IPFS, the InterPlanetary File System, is a protocol for content-addressed storage. A normal web URL is a location, it names a server and a path, and trusts whoever runs that server to hand back the right bytes. IPFS instead names content by a CID (content identifier), which is derived from a cryptographic hash of the bytes themselves. The identifier comes directly from the content, not from where it is stored.
The address is a hash
A CID is computed from the content. Identical bytes always produce the identical CID; change one byte and the CID changes completely. There is no server name baked into it.
Tamper-evident by design
Because the CID is the hash, fetching content and re-hashing it proves you got exactly what was requested. A gateway cannot quietly substitute a different object, the address would no longer match.
Location-independent
Any node or gateway that holds the bytes can serve them, and you can verify the result regardless of who served it. Availability is a hosting problem; integrity is settled by the address.
# the CID is derived from the bytes, the same content → the same CID, anywhere
>ipfs://bafkreiet7m…q5fy2a
resolves to the DID document · the CID is the hash of the document bytes
>sciphr:v5:bafybeih2…x9c3#<sha256>
resolves to the encrypted envelope · SHA-256 verified before restore
Why identity objects are stored off-chain.
The XRP Ledger is a settlement and identity layer, not a file store. Its transaction fields are small and metered, the URI fields SciPHR writes are capped at 256 bytes on the path we validate. An identity needs to publish objects that are far larger than that. Content addressing resolves the tension cleanly: put the tiny pointer on-chain where immutability and ordering matter, and put the full object off-chain where size is cheap, with the hash carrying integrity across the boundary.
Small, ordered, immutable
A CID is a few dozen bytes, it fits inside the 256-byte field with room to spare. The ledger gives it an immutable, timestamped, publicly auditable home, and orders it against every other change to the account.
Large, cheap, verifiable
The encrypted envelope and the DID document live in IPFS, where size is not a constraint. The on-chain hash makes the off-chain copy tamper-evident, so nothing is trusted just because a gateway returned it.
A note on custody
Content addressing does not weaken self-custody. The envelope in IPFS is AES-256-GCM sciphrtext that only your device, your iCloud, or your recovery code can open, and the DID document is public metadata by design, no secret key material is ever in it. IPFS is a storage and integrity layer, never a custody one.
The envelope and the DID document.
Identity creation publishes two content-addressed objects and writes two on-chain anchors that point at them. They share the same shape, a small reference on-chain, the full object in IPFS, but they live on different ledger objects and use different URI schemes for good reasons.
On-chain holds only pointers
- The NFT carries a SciPHR-namespaced anchor with an explicit SHA-256, so the backend can recognize a V5 anchor and verify the envelope before restore.
- The DID carries a standard ipfs:// URI, the CID is itself the hash of the document, so no separate digest is needed.
- Neither anchor can move funds, decrypt the envelope, or alter the DID, only a valid on-chain signature can change the identity.
Envelope → NFT anchor
The encrypted master-key envelope is anchored on an XLS-20 NFT (taxon 3) as sciphr:v5:<cid>#<sha256>. The SciPHR-namespaced scheme lets the backend recognize a valid V5 anchor and reject legacy payloads, and the explicit hash is checked before any restore.
DID document → DID anchor
The DID document is anchored on the XLS-40 DID object as ipfs://<did-document-cid>. The standard ipfs:// scheme is the natural DID form, and because the CID is already the hash of the document, the address carries its own integrity check.
Why the two schemes differ
The envelope is opaque sciphrtext SciPHR must validate and gate on, so its anchor is namespaced and
carries an explicit digest. The DID document is open, resolvable metadata, so it uses the plain
ipfs:// form, whose CID is already the digest of the document.
The DID document is larger than a DID field.
A DID document is not one value, it is a small W3C record. For a SciPHR wallet it carries the wallet verification method (an Ed25519 public key) and the Secure Enclave device key (a P-256 key, plus any other enrolled devices), with the authentication and assertion relations between them. Recovery rules live in the account's on-chain signer list, and the envelope is anchored on the NFT, neither is embedded here. Even so, the document does not fit in the XRPL DID fields, which are capped at 256 bytes per field on the path we validate, so the on-chain DID has to point at it.
Public by design. No seed, no private key, no decrypt capability is ever placed in the document.
// DID document, pinned to IPFS, anchored by DIDSet { "@context": ["https://www.w3.org/ns/did/v1"], "id": "did:xrpl:testnet:rSciPHRwa11et…", "controller": "did:xrpl:testnet:rSciPHRwa11et…", "verificationMethod": [ { "id": "#wallet", // XRPL wallet "type": "Ed25519VerificationKey2020", "publicKeyHex": "ED…" }, { "id": "#device-primary", // Secure Enclave "type": "JsonWebKey2020", "publicKeyJwk": { "kty":"EC", "crv":"P-256", "x":"…", "y":"…" } } ], "authentication": ["#device-primary"], "assertionMethod": ["#wallet"] } // > too large for a 256-byte DID field // > so: DIDSet URI = ipfs://<did-document-cid>
The clean pattern
Publish the DID document to IPFS, take its CID, and write one field on-chain:
DIDSet URI = ipfs://<did-document-cid>. The DID resolves to a complete,
tamper-evident document; the ledger holds a single 256-byte-safe pointer; and the
word "CID" in the UI, the backend, and the security model now refers to something that is
actually on the chain.
What this adds, and what it removes.
This pattern is not free. It adds exactly one dependency to onboarding, and in return it removes a structural mismatch in the current model. The cost is worth naming plainly.
The cost it adds
- IPFS publishing must succeed before
DIDSet, the CID has to exist before the anchor can reference it. - Onboarding now has an off-chain write in its critical path, so it needs retry, a health check, and a clear failure state.
- The DID document's availability depends on the object staying pinned, a hosting responsibility, not a custody one.
The complexity it removes
- No more UI, backend, and security model that all say "DID document CID" while the chain only holds a bare DID string.
- The word CID finally refers to something that is actually anchored on-chain and resolvable.
- Device keys and recovery metadata have one canonical, verifiable home instead of being implied by a string that points nowhere.
- Resolving the DID and verifying the document become the same standard operation, no SciPHR-specific reconstruction.
| Concern | Bare DID string | ipfs://<did-document-cid> |
|---|---|---|
| What's on-chain | A DID identifier only | A DID identifier and a resolvable, hashed pointer |
| Where the document lives | Implied, not addressed | IPFS, addressed by its own hash |
| Integrity of the document | Unverifiable from the chain | CID is the hash, self-verifying on fetch |
| Onboarding dependency | None added | IPFS publish gates DIDSet |
| Model coherence | "CID" names a thing not on-chain | "CID" names exactly what's anchored |
IPFS publishing during onboarding.
During onboarding the DID document is assembled from the wallet and device public keys and published to
IPFS; only once its CID exists is DIDSet master-signed by the device with the
resulting ipfs:// URI. Publishing is a prerequisite: if it fails, onboarding stops
before the anchor is written, so there is no half-state where the chain references a CID that was
never published.
- Generate keys on deviceThe XRPL Ed25519 master and regular keys are created in the Keychain; the Secure Enclave P-256 device key is created in the chip. Nothing leaves the device.
- Assemble the DID documentA W3C DID document is built from the wallet and device public keys, the Ed25519 wallet method, the P-256 Secure Enclave key, and their authentication and assertion relations. Public by design, no secret material.
- Publish to IPFSThe document is pinned to content-addressed storage, which returns its CID. This write is on the critical path and is retried; a failure halts onboarding here.
- Master-sign DIDSetThe device master-signs
DIDSetwithURI = ipfs://<did-document-cid>, alongsideSetRegularKey,SignerListSet, andNFTokenMint. - Backend broadcasts, XRPL settlesThe backend relays the signed blob; the ledger validates and settles. The DID now resolves to a complete, verifiable document.
Deep dive What happens if the IPFS publish fails ▸
The publish step is treated as a hard prerequisite, not a best-effort side write. Because the CID must exist before the anchor can name it, the onboarding state machine will not advance to DIDSet until a successful pin returns a CID.
On a transient failure, the backend retries against the SciPHR IPFS endpoint; the document is deterministic, so a retry re-pins the identical bytes to the identical CID with no risk of divergence.
On a hard failure, onboarding surfaces a clear error and stops before any on-chain write that would reference the document. Nothing is anchored, so there is no orphaned pointer to clean up, the user simply retries when storage is reachable. Routine signing, separately, never depends on the DID anchor at all, it uses the local regular key.
Verifying off-chain content.
The reason this is safe to do off-chain is that integrity travels with the address. Whoever serves the
bytes, the SciPHR gateway at ipfs.sciphr.io or any public gateway, the resolved
document is bound back to the on-chain DID before it is trusted.
- DID document, resolved by
ipfs://<cid>; the CID is derived from the document's bytes, and the backend rejects any resolved document whoseiddoes not equal the DID being resolved. - Encrypted envelope, fetched by its CID and checked against the explicit
#<sha256>in the NFT anchor before any restore, anchor finalization, or metadata display. - Content-addressed, because the CID is a hash of the bytes, any IPFS client that verifies the address detects substituted content; SciPHR pins and resolves through its own gateway at
ipfs.sciphr.io. - No custody crosses the boundary, the envelope stays sciphrtext only its owner can open, and the DID document is public metadata, so off-chain storage never holds anything that can sign or decrypt.
Source of truth
The XRP Ledger records which document is yours, the CID it anchors. IPFS serves that document. Because the anchor is a hash, a mismatch between the two is always detectable, which is why a 256-byte on-chain reference is enough even though the document itself is unbounded.
Live example · XRPL testnet
A real xCIPHR identity. Every value below is the actual on-chain record or the pinned object it points to, you can resolve them yourself.
The DID document's id equals the DID above, and the envelope is verified against the anchor's #<sha256> before any restore. The envelope link returns opaque sciphrtext, by design.