Introduction
This series has 21 earlier posts. They covered how to create, update, resolve, and anchor identifiers on Bitcoin. They did not cover the topic that each operator eventually cares about most: what happens when a key must change.
Keys change for routine reasons and for urgent reasons. An employee leaves. The team replaces a signing device on a hardware refresh cycle. A thief steals a laptop. A key came from a machine that an attacker compromised months ago. An identity system must handle all of these events before it is ready for production use.
This post explains how did:btcr2 handles rotation. It explains how recovery works with the design of this method. It also explains what deactivation means when the history cannot change. Some answers are less comfortable than the promotional claims for decentralized identity suggest. For that reason, this post records them.
btcr2 Has No Recovery Key
The first point often surprises people who know Sidetree-based methods.
Sidetree uses a two-tier key model, and ION uses Sidetree. Each DID has an update key for routine document changes and a separate recovery key for emergencies. The recovery key can replace the update key. The Sidetree spec advises clients to keep update keys and recovery keys separate. In the usual pattern, the update key is on a convenient device, and the recovery key is in a safe. Then the DID survives a compromise of the update key.
did:btcr2 does not use this model. It has no special update key and no special recovery key. To authorize an update, the controller invokes a ZCAP root capability. The update operation identifies this capability as urn:zcap:root:${encodeURIComponent(did)}. The encodeURIComponent() function percent-encodes the colons of the DID, so the URN reads urn:zcap:root:did%3Abtcr2%3A.... The controller signs the invocation with a verification method from the capabilityInvocation relationship of the DID document, and uses the bip340-jcs-2025 cryptosuite.
That is the complete authorization model. If a key is in capabilityInvocation, it can authorize any update. This includes updates that add or remove other keys. If a key is not in capabilityInvocation, it cannot authorize updates, whatever other verification relationships it is in. A key that is only in authentication can log you in to services, but it cannot change the document.
Each model has a real cost and a real benefit. The btcr2 model is simpler: it has one authorization concept, and one place tells you who can change the document. The Sidetree model has a built-in emergency tier. In btcr2, the operator must build that tier. Neither model is better in all cases, but they fail in different ways. Plan around those failure modes.
How Rotation Works
A rotation is an ordinary update. btcr2 has no special operation type for it. You patch the document to replace the key material. The authorization of a rotation is the same as for all other updates.
A BTCR2 Signed Update contains a JSON Patch that describes the change. It also contains a sourceHash, a targetHash, a targetVersionId, and the Data Integrity proof. The spec defines the two hashes as SHA-256 hashes of the JCS-canonicalized document before and after the patch. With these hashes, a verifier can confirm that it applied the patch to the same document that the author of the update used.
A minimal rotation patch replaces the public key of one verification method:
[
{
"op": "replace",
"path": "/verificationMethod/0/publicKeyMultibase",
"value": "zQ3shxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
]
A safer sequence is usually to add the new key first and remove the old key later. That sequence uses a different patch:
[
{
"op": "add",
"path": "/verificationMethod/-",
"value": {
"id": "did:btcr2:k1q...#key-2",
"type": "Multikey",
"controller": "did:btcr2:k1q...",
"publicKeyMultibase": "zQ3shxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
},
{
"op": "add",
"path": "/capabilityInvocation/-",
"value": "did:btcr2:k1q...#key-2"
}
]
The verification method ids in that patch use the absolute form (did:btcr2:k1q...#key-2), not a bare #key-2 fragment. The examples in the spec use the absolute form. The spec also permits relative DID URLs such as #key-2. It requires implementations to resolve a relative DID URL against the document id before they compare it with another reference. A resolver that compares the strings literally does not follow this rule. For that resolver, a mix of relative and absolute forms fails with INVALID_DID_UPDATE.
We recommend the absolute form, because it gives the same result in all resolvers. A lockout of that type is the problem that the add-then-remove sequence prevents.
After you confirm that the new key works, a second update removes did:btcr2:k1q...#key-1. Two updates cost two Beacon Signals. But during the overlap, the old key stays authorized, so a mistake in the new key material does not lock you out.
The Old Key Signs the Update That Replaces It
Many implementers miss this detail. It follows directly from how the resolver works.
The resolve algorithm sets the order of the checks. As of September 2026, the reference implementation follows that order in its resolution loop and in Resolver.applyUpdate(). The steps are:
- Hash the current document, and confirm that the hash matches the
sourceHashof the update. - Check the proof fields. For example,
capabilitymust equal the root capability URN of the DID. The spec says that implementations MAY also derive the root capability, and the reference implementation does not do that derivation. - Find the entry in
capabilityInvocationof the current document that identifies the verification method of the proof. Then get that verification method. - Check the
createdandexpiresvalues of the proof against the block that contains the Beacon Signal. - Build the multikey and the cryptosuite, canonicalize the update, and verify the proof.
- Apply the JSON Patch. Then confirm that the result conforms to DID Core v1.1 and keeps the same
id. - Compare the hash of the result with the
targetHashof the update.
Verify first, then patch. Never patch before you verify.
As a result, the resolver verifies a rotation update against the key set that exists before the rotation. The old key signs its own replacement. The new key has no authority over the update that adds it. The new key can authorize only the updates after that update.
This design is necessary. If a new key could authorize the update that adds it, any person could add a key. But the design has two practical results that are easy to miss:
A lost key cannot sign its own replacement. If the private key is gone, a different authorized key must sign the update that removes it. If no other key is authorized, no other path exists. This is the central constraint of the model. The recovery section below is about how to prepare for it before a loss, not how to react after it.
Earlier updates stay verifiable against earlier keys. A resolver replays the document from genesis. It verifies each update against the key set that was current at that point, not against the current key set. A key rotation does not make the updates that the old key signed invalid. For this reason, an auditor can check the history years later. For the same reason, a compromised key stays in the record permanently, and you cannot erase it.
Recovery Without a Recovery Key
Now consider a compromised key. An attacker has a private key that is in your capabilityInvocation set.
Know your position: the attacker can do all that you can do. The attacker can patch the document. The attacker can add new keys and remove your keys. No tier of authority is above the attacker, because btcr2 does not define one. You and the attacker compete to publish first, and the order rules of the resolver decide the result.
The resolver sorts updates by targetVersionId from lowest to highest, and it uses block height as a tiebreaker. The update itself contains no block height. The anchor is external, through Beacon Signals, and the resolver gets the height during resolution.
The spec states which anomaly raises which error. The three cases are different, so keep them separate:
- A gap in the version sequence (
targetVersionId > current_version_id + 1) raisesLATE_PUBLISHING. - A second update for a version that the resolver already applied goes to a duplicate check. The resolver removes the proof, hashes the update, and compares the hash with the entry in
update_hash_history. If the hashes are different, the update is not a duplicate. It is a different history for the same version, and it also raisesLATE_PUBLISHING. - A
sourceHashthat does not match the current document of the resolver raisesINVALID_DID_UPDATE.
All three errors stop resolution. The resolver does not guess.
This rule is more important than it seems. Suppose that you and the attacker each publish an update for version 5. The resolver does not silently choose one and give your identity to the attacker. The update in the earlier block applies. Then the other update fails the update_hash_history comparison, and resolution stops with LATE_PUBLISHING. The LATE_PUBLISHING checks exist to reject an inconsistent history, not to resolve it arbitrarily.
One condition applies to this result. The resolver ignores an update if its Beacon Address is not in the current document. Thus the second update raises LATE_PUBLISHING only if its Beacon Address is still in the document after the first update.
This result has a good side and a bad side. The good side: if both Beacon Addresses stay in the document, a compromise cannot silently move your DID to another party while resolution looks normal. That condition is important. If the first update removes the Beacon Address of the second update, the resolver ignores the second update, and resolution looks normal.
The bad side: the result can be a DID that does not resolve cleanly for anyone. A contested rotation can stop the resolution of the latest version of an identifier. For a DID that anchors issued credentials, that is a serious operational event.
Thus the real recovery strategy is structural, and you must put it in place before a compromise:
Put multiple keys in capabilityInvocation, and keep them in different custody. For example, authorize a laptop key and a key on an offline device in a locked drawer. If an attacker compromises the laptop key, you still have an authorized key that the attacker does not have. You can publish an update that removes the compromised key. You still compete with the attacker, but you have a key that the attacker cannot use.
Monitor your Beacon Addresses. The spend history of your Beacon Addresses is public, and you can watch it. On a Singleton Beacon, each expected spend is one of your own updates. Thus an unexpected spend is an early indication of a problem, usually well before other signs appear. If you run your own indexed node, this watch is cheap to set up. On a Singleton Beacon, it is a high-value check for a btcr2 deployment.
On an aggregate beacon, other participants share the address, and its spends often announce their updates. There, you must look for an announcement for your DID that you did not submit. You can do this only if you can get the announcement data for each Beacon Signal. That data can come from sidecar data or from CAS, so it is not always public.
Keep the authorized set small and current. Each key in capabilityInvocation has full authority. Consider a set that grew over three years and contains keys of people who left the company. Its attack surface is larger than the owner of the document probably knows. Good rotation practice is mostly the removal of old keys.
Decide your fork policy in advance. If a contested history occurs, a person must decide whether to retire the identifier and reissue credentials under a new one. It is much easier to make that decision in a plan than during an incident.
Deactivation
Sometimes the correct end state is an identifier that you can no longer use. A company dissolves. A project ends. A DID served a purpose, and that purpose is complete.
In btcr2, deactivation is also an update. It is a final update whose JSON Patch adds the property deactivated with the value true to the DID document. The reference implementation has a separate deactivate CLI command, next to the create, resolve, and update commands. The patch that it sends is exactly [{ "op": "add", "path": "/deactivated", "value": true }]. When a resolver finds deactivated: true, it stops: the spec says that this state is permanent and that resolution MUST terminate. From that point, the returned didDocumentMetadata contains deactivated: true.
Deactivation is not the same as an empty capabilityInvocation set. If an update removes all authorized keys, the document is frozen, because no key remains to sign an invocation of the root capability. But by the definition of the method, that document is not deactivated. A conformant resolver continues to resolve it and reports deactivated as false.
Be careful with the word "deactivated". It does not mean what people usually expect.
Deactivation does not delete the identifier. Nothing leaves Bitcoin. The history stays on Bitcoin permanently, and it stays verifiable. Deactivation is the final state in the lifecycle of the document. It is not an erasure.
You can still prove the past. Suppose that a key that was valid at version 4 signed a credential at version 4. That credential still verifies after deactivation, because a resolver can resolve the DID at that version. This is usually the result that you want. Make sure that your verifiers can resolve historical versions, not only the latest version. A verifier that checks only the current document treats each credential from a deactivated DID as unverifiable. That policy is much less precise than the policy that an issuer usually intends.
You cannot undo it. The rule that makes it permanent comes from the spec, not from cryptography. Your authorized keys survive deactivation and can still sign. But a conformant resolver stops when it reads deactivated: true, and the spec treats that state as permanent. Test deactivation on regtest first.
The credential lifecycle is the most likely source of problems. A DID can issue credentials that must stay valid after its deactivation. In that case, verifiers need a policy. We recommend that you write that policy down before deactivation, not after it. If those credentials must not stay valid, the deactivation of the issuer DID does not revoke them. Revocation is a separate problem with a separate answer, and it is the topic of this series in two weeks.
What This Costs
A rotation is an update, so it costs the same as an update: one Beacon Signal. With a Singleton Beacon, that is one dedicated on-chain transaction. With an aggregate beacon, it is a share of one transaction. The add-then-remove pattern costs two Beacon Signals.
The second cost is operational, not monetary. Each rotation makes update data, and each verifier needs that data to resolve past your rotation. If you distribute this data as sidecar data, you are responsible for its retention and delivery. If you publish it to a CAS, the resolver does the retrieval, but you depend on the availability of that store.
If you rotate often, the history grows. You must keep that history, deliver it, and keep it intact with no time limit. This obligation is easy to miss while it is small, and it does not get smaller. A later post in this series covers it.
Conclusion
The rotation model of btcr2 is coherent. It has one authorization concept. It verifies each update against the key set that was current when the key signed the update. Its canonical history stays auditable. The absence of a dedicated recovery key is a real simplification and a real cost. The cost falls entirely on operators who did not plan for a compromise before it occurred.
The plan is not complicated:
- Authorize more than one key.
- Keep the keys in different custody.
- Monitor your Beacon Addresses.
- Keep the authorized set small.
- Decide in advance what you will do if the history forks.
None of these steps needs new protocol features. They need a decision to act before an incident occurs.
Next week: what "different custody" means for an organization. That post also shows where hardware support for BIP340 signatures is good and where gaps remain.