DIDs, handles, and AT-URIs
Identity is the foundation. Before anyone can store a single byte on this PDS, they need a DID; before a client can connect, it needs to resolve a handle to a DID; before a record can be referenced from another record, it needs an AT-URI. This chapter covers all three.
DIDs
A DID (Decentralized Identifier) is a URI
of the form did:<method>:<method-specific-id>. The AT Protocol uses two
methods.
did:plc
The most common DID method on the network. The format is:
did:plc:<24 characters of base32-encoded sha256(genesis operation)>
Example: did:plc:7iza6de2dwap2sbkpav7c6c6.
These are issued by the PLC directory at https://plc.directory. To
mint one:
- The PDS generates a signing key for the new account (k256/secp256k1).
- It builds a genesis operation: a JSON object naming the rotation keys, the verification methods, the handle, and the PDS endpoint.
- It signs the operation with the rotation key.
- It POSTs the signed op to the PLC directory, which returns the DID (derived from the hash of the op).
- The PLC directory stores the op in an append-only log keyed by the DID,
and serves the resolved document at
https://plc.directory/did:plc:<id>.
Rotations work the same way: a signed "rotate" op replaces the keys; the directory verifies the signature comes from a current rotation key and appends to the log. The DID never changes.
⚠️ This means the PDS does not own the DID. It owns the signing keys we put into the DID document. If the user gets their keys, they can migrate to a different PDS without our help.
In our code, src/pds/did/plc.ts will speak to plc.directory. For dev we
can run our own PLC server, but most of the time we just point at the
public directory.
did:web
Domain-derived identity. The DID did:web:alice.example.com resolves to
the document at https://alice.example.com/.well-known/did.json. No
registry, no directory — the domain owner is the source of truth.
The PDS itself uses did:web for its own service DID. If your PDS is hosted
at pds.example.com, its service DID is did:web:pds.example.com, and it
serves its own DID document at /.well-known/did.json.
User accounts on this PDS can also use did:web (point at their own domain) but it's less common because rotation is impossible without re-issuing the domain's static document.
Resolving a DID
Given a DID, you fetch its DID document:
{
"id": "did:plc:7iza6de2dwap2sbkpav7c6c6",
"alsoKnownAs": ["at://alice.bsky.social"],
"verificationMethod": [
{
"id": "did:plc:7iza6de2…#atproto",
"type": "Multikey",
"controller": "did:plc:7iza6de2…",
"publicKeyMultibase": "z<base58-encoded k256 pub key>"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://this-pds.example"
}
]
}
The fields the PDS cares about:
alsoKnownAs[0]— the account's current handle, prefixed withat://.verificationMethod[*]withidending in#atproto— the public key the network will use to verify the repo's commit signatures.service[*]withtype=AtprotoPersonalDataServer— the PDS endpoint.
If those three resolve correctly, everything downstream works.
Handles
A handle is the human name: alice.bsky.social, pfrazee.com,
atproto.com. Per the handle spec, a
handle:
- Is a valid DNS name.
- Can be ≤ 253 characters.
- Resolves to exactly one DID via either:
- A
_atprotoDNS TXT record (_atproto.alice.example.com TXT "did=did:plc:...") - Or an
/.well-known/atproto-didHTTP endpoint that returns just the DID string.
- A
The handle does not own the DID; the DID owns the handle. The link is
one-way (handle → DID) and the inverse (DID → handle) is whatever the DID
document says in alsoKnownAs. If a client looks up a handle and gets a
DID, but the DID's document doesn't list that handle in alsoKnownAs, the
resolution is invalid — the handle was unilaterally claimed and should be
rejected.
This bidirectional check is the trust root of the protocol. If you ever see a "handle does not match DID" error in the wild, this is what's catching it.
src/pds/did/handle.ts will implement both DNS and well-known resolution,
plus the bidirectional check.
AT-URIs
The pointer format:
at://<authority>/<collection>/<rkey>
Where:
authorityis a DID (canonical) or a handle (display only — clients resolve handles to DIDs before storing references).collectionis an NSID likeapp.bsky.feed.post.rkeyis the record key, usually a TID.
Some examples:
at://did:plc:7iza6de…/app.bsky.feed.post/3jzfgg5jfgs2k
at://did:plc:7iza6de…/app.bsky.actor.profile/self
at://did:plc:7iza6de… ← just the repo
at://did:plc:7iza6de…/app.bsky.feed.post ← the collection
Records refer to each other using AT-URIs plus a CID. The CID pins a specific version of the target; the AT-URI says "this record at this location, which currently happens to have these bytes." If the target is edited later, the URI still points to it but the CID is stale.
A like, for example, looks roughly like:
{
"$type": "app.bsky.feed.like",
"subject": {
"uri": "at://did:plc:author/app.bsky.feed.post/3jzfgg5jfgs2k",
"cid": "bafyreigp…"
},
"createdAt": "2026-06-02T18:34:00.000Z"
}
If the post is edited, the like still points at it (via the URI), but the old CID is preserved as evidence of what was liked. The AppView decides how to handle the version mismatch.
TIDs
Record keys are usually TIDs (Timestamp IDentifiers). The format, described in the TID spec:
- 13 characters of base32-sortable encoding.
- Encodes 53 bits of microseconds since epoch + 10 bits of clock identifier (a tiebreaker that varies per process).
3jzfgg5jfgs2k
What matters about TIDs in practice:
- They sort lexicographically in time order. This is what makes the MST nice — collections naturally sort newest-last (or oldest-first), and range queries work as string ranges.
- They're locally generable. Every PDS process picks a clock-id at startup, and the algorithm guarantees no two TIDs from the same process collide. Across processes, you might collide if you both pick the same clock-id and generate at the same microsecond, which is rare enough that we don't try to coordinate.
- They're not secret. A TID exposes the creation time within a microsecond. Don't use TIDs where you need an unguessable identifier.
Implementation lives in src/pds/repo/tid.ts (lands with the repo chapter).
A worked example
Alice opens her client. Here's what happens identity-wise:
- Handle resolution. Client looks up
_atproto.alice.example.comTXT, getsdid=did:plc:7iza6de…. - DID resolution. Client GETs
https://plc.directory/did:plc:7iza6de…, gets the DID document above. - Endpoint discovery. Client reads
service[type=...PDS].serviceEndpoint→https://this-pds.example. - Sign-in. Client POSTs to
https://this-pds.example/xrpc/com.atproto.server.createSessionwith her password. The PDS replies with a session JWT. - All future requests include the JWT and target the same PDS.
If Alice migrates to a different PDS later, only steps 3 and 4 change — steps 1 and 2 still go to the same DNS and the same PLC directory. The DID document gets updated to point at the new PDS endpoint. Federation keeps working.
Try it
Look at a real DID document:
curl -s https://plc.directory/did:plc:ewvi7nxzyoun6zhxrhs64oiz | jq
(That DID is paul.bsky.team's. It's public.)
Notice the same shape as the document above. The PDS endpoint is in
service[type=AtprotoPersonalDataServer]. The signing key is in
verificationMethod.
Rotation
The DID is permanent; the DID document changes constantly. Every change is
a new signed PLC operation appended to the DID's log, with prev pointing
at the previous op's CID. The log is the cryptographic record of every
identity change the account has ever made.
genesis op (seq 0, prev=null)
↓ rotates to
rotate op (seq 1, prev=<genesis CID>)
↓ rotates to
rotate op (seq 2, prev=<seq 1 CID>)
Two keys define the trust roles:
- Rotation key authorises operations. Every op is signed with it; the directory (or our local equivalent) verifies the signature comes from a current rotation key listed in the log.
- Signing key signs repo commits — a completely separate concern. The signing key can be replaced via a rotation op; the rotation key can replace itself the same way.
The simplest rotation is com.atproto.identity.updateHandle: same rotation
key, same signing key, same PDS endpoint — only alsoKnownAs changes. The
new op carries forward every other field from the previous op, signs with
the existing rotation key, and chains to the previous CID. After it's
appended, the PDS atomically updates accounts.handle and emits an
#identity firehose event so subscribers re-resolve the document.
Future rotation kinds use the exact same machinery:
- Signing key rotation —
verificationMethods.atprotobecomes a new did:key. The repo's existing commit history keeps verifying against the old key (it's preserved in the log); new commits are signed with the new key. - Service endpoint change —
services.atproto_pds.endpointchanges. This is account migration: the user's repo moves to a new PDS, the DID document is updated, and federation re-routes to the new endpoint transparently. - Recovery key add —
rotationKeysgrows to include a user-held key alongside the PDS-held one, so the user can recover the account even if the PDS is unreachable.
The user-facing surface for the second two — and any future rotation
that touches a field other than the handle — is
com.atproto.identity.signPlcOperation (chapter 20). It accepts an
arbitrary subset of (rotationKeys, alsoKnownAs, verificationMethods, services) overlaid on the latest op, gated by an email-confirmation
token issued by requestPlcOperationSignature. updateHandle stays
the narrow fast-path for "just rename me"; signPlcOperation is the
escape hatch for everything else.
The DID itself never changes. It was derived from the SHA-256 hash of the genesis op's bytes, and rotations append rather than replace — they can't retroactively change history because every op points back at its predecessor by CID.
⚠️ Difference from upstream. In local-PLC mode we don't publish rotations to plc.directory. The chain is local-only — fine for development and self-contained demos, broken for federation. Flipping
PDS_LOCAL_PLC=falsewould POST each signed op tohttps://plc.directory/<did>; the directory verifies the signature against the previous op's rotation keys and appends to its authoritative log. See chapter 18 — Production.
Exercises
- Look up
bsky.app's handle via DNS. What's its DID? What PDS hosts it? - Pick any AT-URI from a Bluesky post and parse it into
(did, collection, rkey). - Write the TID for right now. (You don't need code; convert microseconds
to base32 by hand if you want, or jump ahead to
src/pds/repo/tid.tswhen it lands.)
Up next
Identity is settled. Time to learn how bytes get addressed in this system: Chapter 05 — Content addressing and DAG-CBOR.