Adversarial ATProto PDS Migration

3 months ago 3

By David Buchanan, 28th July 2025

An ATProto account lives on a Personal Data Server (PDS), and that service needs to be hosted somewhere by someone. There are a lot of options here, like a Raspberry Pi in your closet, a rented VPS in a datacentre, or a batteries-included service like bsky.social.

Each option has its pros and cons, but whichever you choose, you should be able to change your mind at any time, even if your current PDS hosting provider doesn’t want that to happen. Maybe your provider decided to jack up their prices. Maybe your Pi was carried off by a seagull. Whatever happens, there should be a path to recover your ATProto account and migrate to a new PDS, with minimal disruption.

This is an example of "credible exit", one of the ideas behind the design of atproto.

Non-Adversarial Migration

PDS migration in general is documented in the atproto spec. A more practical guide by bnewbold describes concretely how to perform a migration from one PDS to another, using the goat CLI tool.

Bryan's guide however only covers a "non adversarial" migration scenario, in which the old PDS still exists and is cooperating with the migration process.

The "credible exit" story would not be complete without having tools for the adversarial situations too - That's what this article is about. We will also be using goat, with some newly-added features to support this scenario.

I hope one day this process can be made more accessible to regular users, but for now this is an advanced developer-oriented process with lots of manual steps. Proceed at your own risk!

Prerequisites

During an adversarial migration, we can't rely on the "origin" PDS to retain any data, or to do anything at all. So we need to sort a few things out in advance (i.e. before the migration becomes necessary).

Things to do in advance:

  • Create a backup PLC rotation key (very important!)

  • Fetch recent backup of your repo CAR file, blobs, and private data.

Rotation Key

ATProto identity is rooted in DID identifiers, and your DID document points to your active PDS. So, PDS migration involves updating your DID document.

If you're using a did:web, no special preparations are required - control of the DNS name is all that's required to make updates.

Updates to did:plc identities on the other hand require a Rotation Key to authenticate updates. (If you're wondering whether you're using did:web or did:plc, you're using did:plc). Normally a PDS holds a rotation key so that it can make identity updates on your behalf (for example, when you change your atproto @handle).

You can have multiple rotation keys, and they have a priority order. To defend against a PDS that might "turn evil" in the future, you need to enrol a backup rotation key with higher priority than any rotation keys held by the PDS. For more details on this process, see "Registering Identity Recovery Keys via PDS, using goat"

If you're only worried about the possibility of your PDS going offline (which, on balance, is more likely than it becoming actively adversarial), then the priority order of your rotation keys is less important.

Repo Backups

A repo backup is technically optional, but you'll have a very bad time without one.

Without, your identity will still be preserved (for example, all the people who "follow" you on Bluesky will continue to follow you), but all your previous posts and media would be gone.

I go into more detail on backup tools towards the end of this article, but for the purposes of this guide I'll assume you've used the goat CLI to create a full backup like so:

mkdir my_backup cd my_backup goat repo export $ACCOUNT_DID # export your public data (bluesky posts, etc.) goat blob export $ACCOUNT_DID # export blobs (e.g. images, videos) goat bsky prefs export > prefs.json # export your private bluesky preferences

For more details about these commands, refer to Bryan's migration guide. The only difference is that here we're creating a copy of our data in advance, rather than in the middle of the migration process.

The Migration Process

Let's consider a hypothetical scenario:

Your PDS is hosted by cheap-servers-r-us.example.com

You don’t trust Cheap-Servers-R-Us a whole lot, so you’ve set up a backup rotation key and take regular backups of your atproto data (as outlined above).

One day your PDS goes down, and Cheap-Servers-R-Us isn’t responding to your customer support tickets. Oh no!

You decide you want to migrate to better-servers.example.org, and the PDS admin has given you an invite code.

Step 0: Preparation

Install a recent version of goat, and make sure you have all the required information on-hand:

  • Your PLC identity credentials ($ACCOUNT_DID, $PLC_SIGNING_KEY) (where the latter is in "multibase" format and may also be referred to as a "rotation key")
  • The host name of the new PDS ($NEW_PDS_HOST, e.g. better-servers.example.org)
  • An invite code for the new PDS ($INVITE_CODE)

This guide will reference the above parameters as shell environment variables, it's up to you whether you actually assign variables or just substitute the strings manually.

Step 1: Updating the DID Document

To authenticate with the new PDS, we'll need to demonstrate possession of the identity's atproto signing key, declared as a verificationMethod in the DID document.

But right now we don't have the atproto signing key - the old PDS does! So, we need to create a new one and add it to the DID document. Note that this keypair will be ephemeral - towards the end of the migration process, the new PDS will generate its own keypair, and the DID document will be updated correspondingly.

You can create a new keypair using goat key generate, which will print the secret and public keys to stdout in the required formats. You can manage this keypair however you like, but from this point forward I will assume that the shell environment variables $ATPROTO_SIGNING_KEY and $ATPROTO_PUBLIC_KEY are populated accordingly (where the former is in multibase string format, and the latter is in did:key format).

We actually need to make 3 different changes to the DID document (which we will do all in one go):

  • Enrol the temporary atproto signing key (which will overwrite the one previously held by the old PDS)
  • Update the PDS URL to point to the new PDS
  • Remove the old PDS's rotation key(s)

This can be done like so:

goat plc update \ --pds "https://$NEW_PDS_HOST" \ --remove-rotation-key "$OLD_ROTATION_KEY" \ --atproto-key "$ATPROTO_PUBLIC_KEY" \ "$ACCOUNT_DID" > plc_operation.json

You can find the value of $OLD_ROTATION_KEY by looking at the output of goat plc history. If there are more than one, the --remove-rotation-key argument can be specified multiple times.

If the old PDS made an unwanted PLC update that you need to revert, you can specify the --prev parameter, setting it to the CID of the last-good PLC operation - this CID can be found by inspecting goat plc history output. Note that this can only be done for up to 72h after the unwanted operation was made - see the "nullification" section at the end of this article for more.

The goat plc update command doesn’t actually finalize the update, it just prepares the operation JSON ready to be signed and submitted later. At this point you may want to manually inspect the contents of plc_operation.json to check that everything is correct. The really important thing to check is that the public key for your backup rotation key(s) are still declared in the rotationKeys array, and that any other keys are ones you recognize. Most other mistakes are recoverable, but accidentally removing your own rotation keys could permanently lock you out of your account.

After that check, the operation can be signed and submitted to the PLC directory:

goat plc sign plc_operation.json \ --plc-signing-key "$PLC_SIGNING_KEY" \ | goat plc submit --did "$ACCOUNT_DID" -

Note: You can also specify the signing key for goat plc sign via the PLC_SIGNING_KEY environment variable, if you don't want to pollute your shell history with key material. The --plc-signing-key argument is redundant in this example and only used to make things more explicit.

Step 2: Generate service auth token

Generate a service auth token, using the temporary atproto signing key enrolled earlier

goat account service-auth-offline \ --atproto-signing-key "$ATPROTO_SIGNING_KEY" \ --lxm com.atproto.server.createAccount \ --iss "$ACCOUNT_DID" \ --aud "did:web:$NEW_PDS_HOST" \ --duration-sec 3600 > /tmp/service_auth_token

Note: Similarly to PLC_SIGNING_KEY previously, you can alternatively specify the atproto signing key to use via the ATPROTO_SIGNING_KEY environment variable.

Step 3: New PDS Login

Create a login account on the new PDS, and log in to it

1 2 3 4 5 6 7 8 9 10 11 12 13
goat account create \ --pds-host "$NEW_PDS_HOST" \ --existing-did "$ACCOUNT_DID" \ --handle "$HANDLE" \ --password "$NEW_PASSWORD" \ --email "$NEW_EMAIL" \ --invite-code "$INVITE_CODE" \ --service-auth $(cat /tmp/service_auth_token) goat account login \ --pds-host "$NEW_PDS_HOST" \ -u "$ACCOUNT_DID" \ -p "$NEW_PASSWORD"

(goat will persist the login session between invocations)

Note that $NEW_EMAIL and $NEW_PASSWORD can be arbitrary, they can be different to whatever you used on the old PDS (especially the password!)

An issue you may run into here is that $HANDLE still needs to be a resolvable handle. If your old PDS was in charge of that (e.g. if your handle was a subdomain), and your old PDS is down, you will need to set up a new handle on another domain first. The easiest way to do this is to ask the new PDS to set up a handle for you, e.g. by specifying --handle as "myhandle.$NEW_PDS_HOST"

Note that changing your handle on atproto is a relatively seamless process - all in-protocol account references are made via DIDs rather than handles (including @ mentions in Bluesky posts, for example).

Step 4: Migrate the data

goat repo import ./repo_backup.car fd . ./account_blobs/ | parallel -j1 goat blob upload {} goat bsky prefs import ./prefs.json

The above is just a summary, see again Bryan's guide for a more detailed explanation of these commands.

Step 5: Updating the DID Document (again)

The PDS has a set of "recommended" credentials it wants you to install into your DID document. It needs to hold an atproto signing key to sign updates of your repo (among other things), and it needs to hold a PLC rotation key so that it can update your handle if so requested.

goat account plc recommended

The above command will tell you what these recommended credentials are. Based on what it tells you, you can update your DID document like so:

goat plc update \ --add-rotation-key "$RECOMMENDED_ROTATION_KEY" \ --atproto-key "$RECOMMENDED_ATPROTO_KEY" \ "$ACCOUNT_DID" > plc_operation.json

Note that --add-rotation-key will add the new key to the front of the rotation key list. You probably want your backup rotation key to stay at the front (having a higher priority), and if so you will need to manually edit plc_operation.json to correct this, prior to submission.

You can sign and submit this operation like before:

goat plc sign plc_operation.json \ --plc-signing-key "$PLC_SIGNING_KEY" \ | goat plc submit --did "$ACCOUNT_DID" -

Step 6: Activation

At this point, you should be able to log into any atproto app (like bsky.app), via the new PDS. Success!

Future Work

As I said earlier, this process is not very accessible to regular users. There are lots of ways this can be improved, on several fronts:

  • User-friendly backup PLC rotation key setup
  • User-friendly automated recurring repo backups
  • User-friendly tooling for actually completing a migration
  • Tools for monitoring for unexpected did:plc updates, and notifying the user

Aside from just "making it easy", it also needs to be secure. The PLC rotation key mechanism is robust against losing keys (you can have multiple backups, and as long as one works you can still recover). But if someone social-engineered you into installing a malicious key with top priority, that's a bad situation to be in (similar badness-level to disclosing the "recovery phrase" of a cryptocurrency wallet).

Here's some cool projects I'm already aware of in this space:

  • demesne - iOS app for PLC key management (testflight link)
  • bsky.app (the official Bluesky app) has functionality for one-off repo backups built-in: Settings -> Account -> Export my data (but this does not include blobs and private data)
  • bsky.storage - ATProto account backups powered by storacha (Note: bsky.storage is not affiliated with Bluesky!)
  • "Running the iCloud Drive PDS." - A PDS backed by iCloud Drive storage. This was clearly done as a fun demo, but I think there's potential here!
  • AT Toolbox - iOS app with upcoming repo backup features triggerable via Shortcuts.
  • atpairport.com - "Your terminal for seamless AT Protocol PDS migration and backup."

PLC "Nullification" Monitoring

did:plc supports "nullification" (i.e. rollback) of updates within a 72h time window. If your PDS went rogue and updated your DID document maliciously (e.g. removing your backup rotation keys!), you'd have up to 72h to revert it using a higher-priority rotation key. This is what the --prev argument of goat plc update is used for.

With this in mind, if you're particularly distrustful of your PDS host, you might want to set up a system to monitor the DID for updates. As a quick-and-dirty solution, you could use a tool like urlwatch to monitor https://plc.directory/<your_did>/log/audit for updates.

I hope that someone will offer "did:plc monitoring as a service" (you could scale this by monitoring /export rather than polling /log/audit for a specific set of DIDs).

Yubikeys

By the way, I made a bare-bones tool for signing PLC operation using a Yubikey:

https://github.com/DavidBuchanan314/yubiplc

It's basically a drop-in replacement for the goat plc sign command in the above steps, with the PLC rotation key being held on the yubikey. I'll write some better docs for it eventually.

Read Entire Article