Skip to main content
  1. Posts/

Risks of DID:PLC

·2371 words·12 mins
Agent IO
Author
Agent IO
Table of Contents
It’s the cornerstone of identity on Bluesky. What could possibly go wrong?

Disclaimer: I’m not particularly credentialed or expert in computer security. I’m a software developer who works in networking and applications and who has been recently been looking closely at Bluesky and the AT Protocol. These are some of my observations and speculations.

The cornerstone of identity on Bluesky
#

The cornerstone of identity on Bluesky is a website called plc.directory.

On Bluesky and in the underlying AT Protocol, users are often casually referenced with “handles”. Mine is timburks.me. But these handles are just transient names associated with the underlying identities that really matter. Those are called “DIDs”, short for “Decentralized Identifiers”. DIDs securely define identities and associate them with properties like their handles and home (PDS) servers.

The most commonly-used form of DID on Bluesky is the DID:PLC, where “PLC” stands for “Public Ledger of Credentials”. The plc.directory is a centralized directory of records that support DID:PLC identities. These identities are described by documents (representable as JSON) that include handle(s), a PDS hostname, and public keys used to confirm assertions by and changes to the identity.

A walk through a DID:PLC identity
#

Here’s my current DID document:

{
  "@context": [
    "https://www.w3.org/ns/did/v1",
    "https://w3id.org/security/multikey/v1",
    "https://w3id.org/security/suites/secp256k1-2019/v1"
  ],
  "id": "did:plc:ahr5yhciwadehhwm7fotyfju",
  "alsoKnownAs": [
    "at://timburks.me"
  ],
  "verificationMethod": [
    {
      "id": "did:plc:ahr5yhciwadehhwm7fotyfju#atproto",
      "type": "Multikey",
      "controller": "did:plc:ahr5yhciwadehhwm7fotyfju",
      "publicKeyMultibase": "zQ3shsTewvFTtZQkGgAu7wB1MaRAoXbaXUo4gHddEz6o5rnD5"
    }
  ],
  "service": [
    {
      "id": "#atproto_pds",
      "type": "AtprotoPersonalDataServer",
      "serviceEndpoint": "https://inkcap.us-east.host.bsky.network"
    }
  ]
}

My DID is the string did:plc:ahr5yhciwadehhwm7fotyfju. The messy part (after did:plc:) is the first 24-character substring of the base32-encoded hash of a document called a “genesis operation”. That “genesis operation” document made an initial set of assertions about my identity that included my handle, my host PDS, and public keys that can be used to verify signatures of my ATProto repository and revisions to my DID description. These second keys are called “rotation keys” and are critical because they are necessary for making changes to my identity, and to a large extent, they are also sufficient for making those changes. Generally anyone with the rotation key for a DID can use it to post revisions using the did:plc Directory API.

The plc.directory API includes a method called GetPlcAuditLog that can provide the entire history of a DID:PLC. For example, let’s review the history of my DID.

The first operation is the “genesis” operation that created my account with the timburks.bsky.social handle.

{
  "did": "did:plc:ahr5yhciwadehhwm7fotyfju",
  "operation": {
    "sig": "wN6vlaNCKdRj9RICh5XHreliJI4sIsuLQz6IcAdfZ_dMmkrEtJk3HxduBfPnEpgSMfB2zGj6O6Isz38M-M_JwQ",
    "prev": null,
    "type": "plc_operation",
    "services": {
      "atproto_pds": {
        "type": "AtprotoPersonalDataServer",
        "endpoint": "https://bsky.social"
      }
    },
    "alsoKnownAs": [
      "at://timburks.bsky.social"
    ],
    "rotationKeys": [
      "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg",
      "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK"
    ],
    "verificationMethods": {
      "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"
    }
  },
  "cid": "bafyreiab4pobysfqazbz5thzlu6bknflmd3hjjkjfegf33teb2ciqoiomy",
  "nullified": false,
  "createdAt": "2023-05-23T00:25:32.252Z"
},

Just as a sanity check, I used this operation to recompute my DID. First I encoded it with CBOR, then I computed the SHA256 hash of that, and finally I base32-encoded that using the lower-case “standard” encoding from RFC 4648. The result was ahr5yhciwadehhwm7fotyfjuvnqpm5ffjeuqyxpomqhijcbzbzta, and the first 24 characters of that matched the value in my DID:PLC.

Other checks that we can make on this operation include:

  • verifying that the cid is the correct content ID for the operation
  • verifying that the operation signature corresponds to one of the specified rotation keys

Official details are in the did:plc Method Specification:

“The PLC server validates operations against any and all existing operations on the DID (including signature validation, recovery time windows, etc), and either rejects the operation or accepts and permanently stores the operation, along with a server-generated timestamp.”

Here the “etc” leaves open the possibility that the directory does some additional verification, which we can observe by submitting a genesis operation with a missing handle. When we do that, the directory returns an error:

Error: POST failed with status code 400 and message: {"message":"Not a valid operation: {\"type\":\"plc_operation\",\"rotationKeys\":[\"did:key:zQ3shdhViL4J8pKwWZw3DZy169SYMDpa7Ekc8LuG262BFZRXX\"],\"verificationMethods\":{\"atproto\":\"did:key:zQ3shbncsU7Wc4xqPYzgB33DzXvqFnthtTNhkcmDBkT6kxYrV\"},\"alsoKnownAs\":null,\"services\":{\"atproto_pds\":{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":\"https://example.com\"}},\"prev\":null,\"sig\":\"TMAPwkU1TXPwHH0kSXH8_CmytgVi_7AW_O-3YU8vOLU8A6-A25Yw1IXZ911qp2LXzn4Jr8asLO9ywx28I3Evig\"}"}

Getting back to my DID history, the second operation changed my handle to timburks.me.

{
  "did": "did:plc:ahr5yhciwadehhwm7fotyfju",
  "operation": {
    "sig": "yMoYhyzeI8hqmCw9xRRzHPfR-zA8kCBv6nLDQjWAQxZL7l1qHt2iyuyPu93WLCUoIqdh4VUx-NTftmpIuF4B1w",
    "prev": "bafyreiab4pobysfqazbz5thzlu6bknflmd3hjjkjfegf33teb2ciqoiomy",
    "type": "plc_operation",
    "services": {
      "atproto_pds": {
        "type": "AtprotoPersonalDataServer",
        "endpoint": "https://bsky.social"
      }
    },
    "alsoKnownAs": [
      "at://timburks.me"
    ],
    "rotationKeys": [
      "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg",
      "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK"
    ],
    "verificationMethods": {
      "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"
    }
  },
  "cid": "bafyreicopfe74uzej7ndtd7x7yigwstm2r7i7qhwchdw55oyy53doou4kq",
  "nullified": false,
  "createdAt": "2023-05-23T00:35:35.129Z"
},

The third operation moved my PDS from bluesky.social to inkcap.us-east.host.bsky.network. Note that it also changed my atproto key in the verificationMethods section to the value that is currently in my DID document.

{
  "did": "did:plc:ahr5yhciwadehhwm7fotyfju",
  "operation": {
    "sig": "u-xzR8oOrFuMSbLy7kNBgaZIiZN0dFMyPcGwROirsLlucqQKCSR771a4b_XRJvklD53cpxNYd2OjCoqP-yyuJw",
    "prev": "bafyreicopfe74uzej7ndtd7x7yigwstm2r7i7qhwchdw55oyy53doou4kq",
    "type": "plc_operation",
    "services": {
      "atproto_pds": {
        "type": "AtprotoPersonalDataServer",
        "endpoint": "https://inkcap.us-east.host.bsky.network"
      }
    },
    "alsoKnownAs": [
      "at://timburks.me"
    ],
    "rotationKeys": [
      "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg",
      "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK"
    ],
    "verificationMethods": {
      "atproto": "did:key:zQ3shsTewvFTtZQkGgAu7wB1MaRAoXbaXUo4gHddEz6o5rnD5"
    }
  },
  "cid": "bafyreigookhkgoyetdslrdgjubhl5yiiov3ostvjdyo2dfhz6xufnv6yze",
  "nullified": false,
  "createdAt": "2023-11-10T00:45:43.942Z"
}

I didn’t directly make any of these revisions. Bluesky did them for me. The first revision, the handle change, was done at my request, but the second was done behind the scenes when Bluesky moved my account to a new PDS.

What is really needed to modify a DID:PLC?
#

Signing with the rotation key isn’t quite enough to change a DID. The plc.directory server must also accept the revision, which it might not do if some basic requirements aren’t met (like, as we’ve seen, having a handle). So although anyone with sufficient tooling could construct a genesis operation, a DID, and a chain of revisions, they are only considered valid if they can be read from the PLC Directory.

It seems ironic and inconsistent to have a centralized authority for a distributed identity, and it’s not strictly necessary. We simply need multiple trusted sources of DID documents that can provide both the details associated with a DID:PLC and some assurance that these identities are legitimate. We don’t even have to agree on our authorities! If we have different authorities, some identities will be unresolvable, but that’s not a terrible problem. There will be some identities that we don’t want to resolve, and a federated network of PLC directories could respond to queries when new identities are encountered.

The assurances that a PLC directory might provide include that the signatures on the revisions are valid and that the DID hash matches the original genesis operation. But these checks can be made by anyone (again with sufficient tools). No central server is required to verify a chain of PLC operations. However, there’s one thing that we still need from the directory: trusted timestamps of when each operation was accepted. This is ultimately what makes the directory authoritative.

Proposal: PLC directories should sign operations
#

Do we really need to rely on calls to the PLC Directory to verify our DIDs? Currently we do, but since we only rely on the directory for trusted acceptance times, future PLC directories could generate signed receipts for the operations that they accept. With those signatures, operation signers would act like notaries and DID chains could be fully verified without a centralized online authority.

This could (and probably should) be as simple as adding a sig field to each log entry alongside the did, cid, operation, nullified, and createdAt fields. With signatures, DID document chains could be portable, cached anywhere, and DID consumers could decide which notaries (operation signers) to trust. Such independent notaries might have instance-specific requirements for DID registration like age or residency verification.

Exploring the PLC Directory logs
#

Currently all valid DIDs (and many invalid ones) are in the common plc.directory. As we’ve seen, it has an API, and the PLC Directory API includes a method that we can use to export its entire operation history for indepedent caching, verification, and analysis.

There are a lot of operations! At this moment (approximately midnight GST on March 10, 2026), there are 85511 “pages” of exported transactions, with each containing 1000 transactions.

In these 85 million+ transactions, there are some curious surprises. A big one is that more than 17 million of these transactions create DIDs associated with a non-existent server called pds.trump.com. This is a shocking percentage of values stored in the logs (20%), and an annoying burden to place on downstream services that rely on already-bulky plc.directory exports. This undoubtably is a consequence of the directory service being unauthenticated: it accepts posts of any correctly-structured and hashed (or signed) document.

A second surprise comes when we look at the rotation keys specified in the operations. A recent count found 122,014,486 unique rotation keys, with 121,433,159 of them used exactly once. Less than 2600 were used more than 10 times, and many of the ones with the highest counts (with two exceptions) appear to be associated with experimental “science projects” on the directory. Generally those keys have tens of thousands of references, but two at the top stand as extreme outliers.

These two keys are the most-used rotation keys in the logs and are listed with their number of references:

did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK (48,911,133)
did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg (48,915,070)

If you are an extremely attentive reader, you’ve recognized these as the rotation keys in my DID operations. These, unsurprisingly, belong to Bluesky, and they seem to be used by Bluesky for all new users. In external deployments of the Bluesky PDS, account creations are done by PDS instances, and we find that the Bluesky PDS implementation reads its key from the PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX environment variable.

We can hope that these keys are being managed by a secure secrets manager like Hashicorp’s Vault, because if they fell into adversarial hands, they could wreak havoc. Every identity created by a Bluesky server could potentially be rewritten to a new username or to point to an alternate PDS.

Let’s dwell for a moment on that risk. Changing a username introduces chaos, but changing a PDS can allow serious harm and potentially financial damage to be done. More and more, services are being built that rely on ATProto OAuth to authenticate users. The PDS is critical for verifying identity. If the PDS for an account can be changed, then so can the password (since a user’s password is stored in their PDS), and any services that rely on ATProto identity can be inappropriately used by an adversary.

It’s clear that any PDS operator has a great responsibility to its users, and in the future this may include a fiduciary responsibility. If a PDS loses keys or in some other way allows its users to be impersonated, it could be held liable for financial losses that are incurred as a result.

Recommendations for safer identities
#

From the above observations, some recommendations emerge:

Highly-shared rotation keys should be avoided.

These are extremely juicy targets and are single points of failure with a vast blast radius. While I’m sure there were reasons for using a single pair of keys, it makes me very uncomfortable and I really think that Bluesky should stop doing this. How many Bluesky team members have had access to these keys? Have any of them left the organization? However careful Bluesky is with these keys, losing control of one would undermine every identity that they’ve created and throw the whole system into chaos.

PDS operators must take extreme care with their rotation keys.

Just like Bluesky, every PDS operator, including small operators of “friends and family” PDS instances, needs to be extremely careful with their rotation keys. You might not see much risk now, but as ATProto grows, so will the risks of identity loss.

PDS implementations should allow per-user rotation keys.

Currently the Bluesky PDS (and likely other implementations) expects to have a common rotation key (or keys) for the accounts that it manages. This requirement should be relaxed to allow per-user rotation keys and even the possibility of the PDS not having rotation keys at all. Normal PDS operation has no need for rotation keys, only a signing key, and that signing key does not have the same loss-of-identity risk.

New identities should be created off of Bluesky with DID-specific rotation keys.

This is the most effective way to keep an ATProto identity secure. PDS users should have the option of creating their own DIDs with rotation keys that they generate and secure somewhere apart from their PDS.

Migrating users should edit out the Bluesky keys ASAP.

When an identity is moved off of a Bluesky PDS, it should be updated to remove the Bluesky rotation keys. This isn’t foolproof: these keys are still in earlier revisions in their document chain, so they could be used to rewrite an identity from any revision where they were listed. That’s a bit less worrisome because it would require compromising the revision history on plc.directory and any mirror, of which there are already many. Notice the importance of mirrors for protecting identities from hijacking!

Relying parties that use PDS OAuth should require 2FA.

This should be obvious. Because of the risks of rotation keys, PDS OAuth will often (perhaps always) be less trustworthy than OAuth from other providers, particularly established ones with strong in-house security systems.

The plc.directory operator must tightly monitor and control use of the Bluesky keys.

Hopefully this is already happening! If the Bluesky keys are cracked or stolen, the PLC directory could limit damage by preventing them from being used without some other proof of identity and noticing if an unauthorized signature ever appears (🚨🚨🚨). Bluesky team, if you’re doing this already, that’s great!, and if not, this suggestion is yours for free.

Predictions about DID:PLC and ATProto OAuth
#

Finally, here are a few thoughts about things that I think are likely to happen.

Multiple PLC directories will emerge, each potentially having its own policies for DID creation and identification. For example, a PLC directory could require a user identity to register a DID, or require an invite code that could be used to establish a chain of responsibility for bad actors. Ideally, directories will be commoditized and the real authority will be in the “notaries” that sign DID:PLC operations.

PDS operators and implementations will differentiate on security as ATProto usage spreads beyond Bluesky into areas of greater risk and potential impact.

PDS operators will limit use of OAuth after one or more get burned by the liability of providing an identity in an imperfect world. A PDS can refuse to provide OAuth services to certain providers and can make it clear that it is not to be used for certain kinds of applications, like financial or crypto.

But people will use ATProto and ATProto OAuth anyway due to its incredible portability and easy-of-use.

What do you think?