My PDS migration worked. Sort of.
My PDS migration worked perfectly but my rotation keys were still all Bluesky's. This is how I got them back!
watched a video last weekend, "AT Protocol makes web dev fun again". Mostly out of boredom, a little bit of curiosity. In true "distracted by new shiny thing" fashion, I immediately wanted to do something.
Then I stumbled on "There Are No Instances in atproto", and I got more excited (and finally understood why Mastodon and Bluesky are different). The RSS comparison is especially good: all of my content lives at my address, and other apps can come read it if they want. Except it's not just blog posts or articles; I can have everything in there and host it myself.
I kind of felt like I did when I was 13/14/15, first discovering how to, like, build websites and put things on the internet. I wasn't bothered about whether anyone else would really like it except me because they were my little websites, about / for me. If people were interested enough, they'd find it (which, erm, they never did, but that's ok, because it was for me).
So now I've got a self-hosted PDS, and I rebuilt this website, and it's largely powered by atproto, and for now I'm very happy with myself. I'll write more stuff about my little adventure, but for now I'm going to document how I dealt with PDS rotation keys because that was by far the most unknown part of it for me (aside from yk, generally learning how atproto even works).
The migration from Bluesky to my self-run PDS did work. It uses Cirrus, and my Bluesky posts are pushed to it; I can push my own stuff to it, and it all lives in one neat little feed that I can render however & wherever I want.
The aftermath (the cleanup and actually fully owning my "identity") was awkward, and perhaps I made it awkward for myself by jumping in too quickly. I ended up with a set of rotation keys (the ones that let you change your identity), but Bluesky still owned all of them, and I didn't like that. So, here's what I did.
Before we start, I just wanna lay out some definitions, because this is an acronym-heavy post.
| Term | Un-acronymed | What it is |
|---|---|---|
| atproto | The AT Protocol | An open protocol for social apps where your identity and data live in your own repository instead of inside a single app. Bluesky is obviously the biggest app built on it, but anyone can build new apps or define new record types. |
| PDS | Personal Data Server | The server that hosts your atproto repository and signs new content on your behalf. Someone else (e.g. Bluesky) can run it for you, or you can run it yourself. |
| DID | Decentralised Identifier | Your permanent globally unique ID in atproto, it should never change (even if you change your handle or move PDS). DID isn't an atproto thing but is a W3C standard of its own. |
| PLC | Public Ledger of Credentials (previously: placeholder) | This is the DID method most atproto accounts use (did:plc) and the directory that powers it. Your identity's current state (handle, PDS, keys) lives in the PLC directory as an append-only log of operations. |
How DID I discover it?
All atproto accounts have a DID. This isn't secret. Mine is did:plc:up24cb5cw2fhaijo2sztkw3a. did:plc identities (which is what most people have) have a current state (your handle, your PDS, which keys it trusts), and this lives in the PLC directory as an append-only log of signed operations.
In this state, there are two kinds of keys:
| What it's called in the DID doc | What it's for | |
|---|---|---|
| Signing keys | verificationMethods | Every commit to your repo is signed with one of these. |
| Rotation keys | rotationKeys | Changes to your identity (moving PDS, changing your handle, changing your keys) |
So, to add a new rotation key, you need the secret part of one of your current rotation keys. The obvious part (…in hindsight) is that you cannot add a new rotation key using that same new rotation key to sign the operation!
If you don't have access to any of the existing rotation keys, you cannot get in.
You can pull your identity's current state using the goat CLI:
$ goat plc data [your handle]
Which is what I did, followed by a moment of panic.
The (possibly self-created) trap that got me
Like I said, my PDS is Cirrus. It's just for me, runs on Cloudflare Workers, and uses a Durable Object for the repo. It's still experimental. I'm on Cloudflare's free tier and haven't had to pay for anything yet. It was super easy to get set up and deploy.
The unobvious part was that, yes, it set up my identity seemingly correctly:
- My Cirrus signing key was in there.
- My PDS endpoint pointed at my worker.
- I had some rotation keys.
But after some digging, I realised the rotation keys weren't mine; they were Bluesky's. They'd been carried over unchanged from before the migration. From what I can tell, this is a conscious decision by Cirrus (there's a comment in the code to the effect of "keep existing rotation keys"). So, the migration updates your signing key, it updates your PDS endpoint, and deliberately leaves the rotation keys alone (maybe this is a sensible default, and maybe you're supposed to have a better understanding of all of this than I did before you go all-out self-hosting your own PDS).
I guess what makes this unobvious (to me) is that nothing was broken, because the rotation keys aren't used for publishing or serving your data. Every part I needed to interact with to get my content into the PDS, out of the PDS and into my website worked perfectly.
But the migration felt incomplete; if I'd gone to all the effort of self-hosting a PDS and owning all of my data, I'd have really liked to fully own my identity too.
How I fixed it
So I needed to be able to add new rotation keys, and to do that, I needed to use one of the rotation keys that had already been attached (so I needed Bluesky to sign an operation on my behalf).
Bluesky's PDS helpfully provides an API for signing PLC operations: com.atproto.identity.signPlcOperation, which requires a full-password session (read: not an app password) plus an email token.
They also have com.atproto.identity.submitPlcOperation, but this one needs your identity pointing at Bluesky's own infrastructure (read: not a self-hosted PDS). signPlcOperation will sign any well-formed operation even though my DID now points at a PDS that isn't theirs.
So, without further ado, here are the rough steps that worked for me. You should do your own research before following this exactly because things change.
- Log in to my old Bluesky account by DID, explicitly against
bsky.social.goat account login --pds-host https://bsky.social -u [your did] goat account plc request-token- you'll get a 2FA token from Bluesky by email- Build the operation JSON. This needs to be your full current identity state, with your new key prepended to
rotationKeys. The position matters because earlier keys have higher priority, and I wanted mine first (a higher-priority key can cancel out operations signed by lower ones within a 72-hour window). You can generate a new key withgoat key generate; keep the secret part safe, and only put the public key into the operation JSON. goat account plc sign --token <your 2FA token> your-json-file.json- Bluesky's authorised key will sign it.- Check over the signed operation. All the fields. Make sure it's how you'd expect (only the new rotation key(s) added; everything else should be the same).
- Submit it:
curl -X POST https://plc.directory/<your did> -H 'Content-Type: application/json' -d @signed-your-json-file.json - Verify against the live directory that your key is now in
rotationKeys
At first, I was nervous about this, like I was exploiting something, but we're not bypassing any security controls. The directory does and should accept validly signed operations. I just found it fairly unobvious until I dug around and read source code.
Finishing the job
Okay, so at this point, I now have four rotationKeys, and I own one of them (I added my Cirrus signing key as another, and Bluesky owns the remaining two). Better than before, I can't be locked out anymore. But I still don't like it. Bluesky could still, like, overwrite my identity if they wanted to (although I can't imagine why they would want to).
I want two keys, and I want to own both of them, and I want them stored in different places, and I want them to be offline. That means if I lose one, I can use the other to rotate it out and not panic.
The problem here, though, was that the goat CLI only works via a PDS. It always wants a PDS to sign the operation, which conflicts with my decision that both of my keys will be offline (and not attached to any PDS, Bluesky, or Cirrus).
I made a script, using the @did-plc/lib package, and it worked great. It checks your key-pair is valid, creates an operation to update your rotation keys (your existing offline key plus a new one), and submits it. It has a dry-run mode so you can verify before actually submitting it.
When you want to compare the dry-run's prev with your actual last operation, you'll need to get that from the audit log curl -s https://plc.directory/<did>/log/audit | jq '.[-1].cid'.
The script automatically generates prev from the last operation, so a mismatch would mean something was added to your log between the snapshot and now (e.g. an operation that you didn't do).
This could be alarming to you (it would be to me), but all I can say is: look up how to cancel unexpected actions with your higher-priority rotation key.
// save as e.g. plc-update.mts — top-level await needs ESM
// !!IMPORTANT!! This script will modify your permanent identity when not in dry-run mode. Read it and understand it before running it.
import { P256Keypair } from '@atproto/crypto'
import * as plc from '@did-plc/lib'
import { base58btc } from 'multiformats/bases/base58'
const DID = 'your-did'
// Key 1, existing offline recovery key
const KEY_1 = 'did:key:...'
// Key 2, the new offline key
const KEY_2 = 'did:key:...'
// Before you submit, make sure you hold the secret half of all of these keys. This operation replaces the whole list.
const NEW_ROTATION_KEYS = [KEY_1, KEY_2]
const secret = process.env.PLC_SIGNING_SECRET
if (!secret) throw new Error('Set PLC_SIGNING_SECRET to the secret for KEY_1')
// goat emits secrets as multibase: base58btc with a 2-byte multicodec prefix
const bytes = base58btc.decode(secret)
const keypair = await P256Keypair.import(bytes.slice(2))
if (keypair.did() !== KEY_1) {
throw new Error(`${keypair.did()} not the expected signer.`)
}
console.log('signer key verified:', keypair.did())
const client = new plc.Client('https://plc.directory')
const lastOp = await client.getLastOp(DID)
if (!lastOp || (lastOp as { type?: string }).type === 'plc_tombstone') {
throw new Error('Unexpected last op')
}
const op = await plc.updateRotationKeysOp(lastOp as never, keypair, NEW_ROTATION_KEYS)
console.log(JSON.stringify(op, null, 2))
if (process.argv.includes('--submit')) {
await client.sendOperation(DID, op)
console.log('Submitted. Verify: goat plc data <handle>')
} else {
console.log('\nDRY RUN, nothing submitted. Re-run with --submit when happy.')
}
So, after all that, I ended up with:
- Two rotation keys, both offline and both mineee.
- My PDS can sign my content but cannot change me.
- Identity changes can only be done by me, using my offline keys, and I have to submit them to the directory myself.
Although it does mean I'm screwed if I ever lose both of my keys.
If you've migrated to a self-hosted PDS, maybe you want to check your rotation keys and see whose they are.
$ goat plc data <your handle>
Everything here describes did:plc and the tooling as of mid-2026; Cirrus's migration behaviour and Bluesky's PDS internals may change.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.