did:web 101

How the 1% live on the Atmosphere


Background

I'm pretty special. Like, really special. As of right now, this Atproto account is one of ~200 other accounts like it, accounting for just 0.0006% of the Atmosphere.

Before we dive in, let's take a look at @pfrazee.com; a well-established account that's been here since the dawn of time. Using one of the many methods to resolve a handle, we'll discover the DID is did:plc:ragtjsm2j2vknwkz3zp4oxrd .

A DID — Decentralised Identifier — is a standardised way of keeping a "fixed" identity across a decentralised network giving data a "strong reference", allowing changes to the account without breaking references; for example, when the user changes their handle (i.e. @paul.bsky.social ➔ @pfrazee.com), replies to a post won't suddenly break, as they reference a DID not a handle. There are many DID types (specced as did:<type>), used across other networks to resolve similar problems, and are all stored as a reusable modular JSON document; Atproto supports two: did:plc and did:web .

When you signed up for Bluesky, pckt, or any of the other many Atproto apps out there, you were blissfully unaware your account was being created on a PDS with a did:plc identity, which is what 99.9994% of users on the network did. This DID type is known as Public Ledger of Credentials (originally PLaCholder), and was created by Bluesky PBC to resolve various problems with currently-existing DID types. During the process of signing up, it dials up an external service to register a signing key — which the PDS then stores a private key itself, to allow it to change the DID document in the future — which in turn returns the did:plc:<id> the account can be created against.

This "service"? plc.directory. If we take Paul's DID and do a request to this directory, we'll see the following DID document:

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

A few fields worth of note: alsoKnownAs is where the handle lives (at://pfrazee.com) — although an array, Atproto only supports the first one in the list — service.serviceEndpoint points to the PDS this account lives on, and verificationMethod holds the public key used to sign commits. We'll come back to these.

While other directories can (and do, but as a mirror) exist, practically everything on Atproto points to this one service. Despite the above DID document making no reference to plc.directory itself — proving the standard itself is a true decentralised DID type — the reality is the current deployment of the Atmosphere currently requires this centralised service. Since its inception, plc.directory has been a constant crutch across the Atmosphere, being the only part of the network that isn't truly decentralised. Unfortunately, efforts to try and distribute this service seem be going nowhere fast, but there have been efforts to make it more independent from PBC, such as it being housed under a Swiss Association.

Despite that niggle, did:plc, and how Atproto is architectured around it, isn't all bad. PLC has an immutable history log; every handle change, PDS migration, and key rotation, is logged, known as a "PLC operation". Take a look at @pfrazee.com's PLC logs on pds.ls: this data is being pulled from plc.directory itself, providing no API for destroying previous logs. However, the real win, is the process remaining entirely transparent; no manual key generation, no self-hosting, and no extra keys to store. Most users don't care how it works, why it works, or how to make it work: they just want the juice. And, so far, that's been a barrier in getting normal users to use decentralised services.

But what if there was another way? Let's resolve @ducky.ws. You know why we're here, it's the entire reason you clicked on this article: this resolves as did:web:didd.uk. Punch that into plc.directory and you'll be greeted with a 400, with no DID document in sight. This is a did:web.

Stepping Outside the Directory

Unlike did:plc identities, did:web are not only decentralised, but entirely sovereign. You truly own it — well, almost. To resolve my DID document, we must take the domain from did:web:didd.uk — so, didd.uk — and request /.well-known/did.json against it, returning the following 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:web:didd.uk",
  "alsoKnownAs": [
    "at://ducky.ws"
  ],
  "verificationMethod": [
    {
      "id": "did:web:didd.uk#atproto",
      "type": "Multikey",
      "controller": "did:web:didd.uk",
      "publicKeyMultibase": "zQ3shYRepkfnXhDjKBmvBVNtu2tswxPjjTDgKWTUcuFdt7xtH"
    }
  ],
  "service": [
    {
      "id": "#atproto_pds",
      "type": "AtprotoPersonalDataServer",
      "serviceEndpoint": "https://zio.blue"
    }
  ]
}

Instead of the DID document being registered on a central service, it exists behind a domain the user owns, with a web server providing the DID document itself. You'll also see it's practically identical to a did:plc DID document, save for did:web: instead of did:plc: in the appropriate places. There's nothing mechanical behind this: there is a literal static did.json file living on my server, which I generated some time ago.

So, where's the catch? Well, there's a few:

  • Unsupported Apps: As we'll discuss later, there's a handful of apps that can't resolve a did:web. A responsive developer will likely fix the issue — point them to this article 😉 — but if they don't, you've got a brick wall to contend with..
  • ICANN Centralisation: There's still some form of centralisation here, and you're entirely at the mercy of your registrar. There have been cases of registrars being hostile, or forcibly removing domains from people — though this has only happened in very extreme cases. You're also relying on yourself to actually pay for the domain, and I can say from experience how easy that is to forget about.
  • Your Responsibility: Self-hosting a DID document comes with responsibility, and especially if you're also managing the PDS it resides on. Unlike did:plc, if you lose the signing key, there's no log to fall back on. As with anything self-hosted: backup, backup, backup!
  • DID Burning: This is what happens when you delete a did:web. Like a did:plc, an account should be considered end-of-life once the DID is tombstoned. Deleting a did:web causes Bluesky's AppView (api.bsky.app — the layer between the app and the PDS that hydrates and proxies data) to mark it as permanently deleted and non-recoverable. Other services may not do this: their AppView might ignore the deletion, or talk to the PDS directly and never "see" it at all.
  • Generation Mishaps: As we'll discuss later, you have to generate this did.json manually. If something goes wrong during that process and you still manage to create an account against it, it likely won't work correctly — and destroying it will burn it.

But wait! There's some pros:

  • Editing Ease: Unlike did:plc, editing the DID document is easy. Updating the handle, the PDS endpoint, or rotating the key requires no "PLC ops" (though those can be reversed within a set window anyway). You just... nano did.json. Done.
  • No Paper Trail: Depending on which angle you're looking at it, this could be considered a con, but did:webs inherently have no built-in history (did:webvh does, but it's not supported by Atproto). No prying eyes to notice your mistakes, or potential testing cluttering up logs.
  • plc.directory Hostility: This one's more theoretical, and I generally disregard it, but it's worth mentioning: if plc.directory ever became hostile or simply vanished, did:web accounts would survive. But in that scenario, if the current centralised did:plc setup is still in place, Bluesky — and the whole Atmosphere with it — has reached the end of the road. The network can't exactly survive with 200 users, can it? This isn't the fediverse.
  • DID Understanding: Creating a did:web was a "Ah, I get this moment!" for me, and I know I'm not the only one. did:plc is more complex, while did:web is fairly basic in comparison, allowing you the chance to learn the fundamentals of how exactly it operates and Atproto commits are signed against it.
  • Cool Factor: My DID is better than yours. You can't deny that. Betcha can't remember your did:plc off the top of your head? Plebs.

And a few side notes:

  • In the Atmosphere, the did:web domain and handle (at://<handle>) are entirely separate entities and don't need to match. That's the case with my account (at://ducky.ws, did:web:didd.uk), for four reasons:
    • I originally used didd.uk for testing (known as "did:duk" — get it?), before moving onto it as my main account. Because an account is permanently tied to its DID and cannot be switched, the domain is now a permanent part of this account.
    • I live in the UK (.uk), not West Samoa (.ws). Using a .uk domain removes the risk of a foreign registrar reclaiming a domain from someone with no legal association with the parent country.
    • I "burned" did:web:ducky.ws some time ago, entirely by accident, not knowing the outcome. While other people have had luck reactivating a burned did:web I would not personally trust this.
    • It serves as a demo between a split handle/domain.
  • As is true for did:web, did:plc being a standard DID method, they are both usable outside of the Atmosphere. If you're so inclined, you can add extra data to your current did:plc and use for your Atproto account for other services.
  • A did:webs DID document going offline isn't the end of the world, at least on the Atmosphere. Our PDS hosts two "dead" did:web accounts: did:web:york-07-b2-v4wan-170002-cust477.vm25.cable.virginm.net and did:web:adsl-77-86-9-221.karoo.KCOM.COM . Both have had non-existent DID documents since Feb '25, but the profiles continue to be visible on Bluesky (did:web:york-07-b2...ginm.net, did:web:asdl-77-86...KCOM.COM), and I'm even able to still login. What I can't do is write to them anymore — no posts, likes, or any other records. Curiously, the handles still remain valid too. If the did.json ever came back on either domain, these accounts would start working normally again.
  • Migrating DID methods is not possible. Although technically possible (for example, assigning aliases in the DID record), this would cause so many edge cases and headaches for developers, the trade-off just would not be worth it. It's also never discussed in the DID specification. So don't even cross your fingers — it won't happen.

did:web for the Human

Back in the dark old days, did:webs had to be manually created. This is something I have never personally done successfully, so I will not being going into detail at this time. Instead, there are a number of tools to help facilitating a creation of a did:web:

Before you go through any of these steps though, some things to consider:

  • Serving the DID Document
    • Can you host the /.well-known/did.json reliably? Or, if someone else is hosting it for you, can you trust them not to suddenly go hostile?
    • Are you keeping things backed up properly? This includes the DID document, and, if you're hosting it yourself, your PDS?
    • Atproto is agnostic about the IP the DID document comes from. You can host it behind Cloudflare, other proxy services, a dynamic IP from your ISP, or anything else with an unstable IP. Keep in mind, it must be available via IPv4.
    • Do not gate the DID document to a certain set of IPs. It needs to widely available.
    • CORS will need to be open (i.e. Access-Control-Allow-Origin: * header). Some apps will need to resolve on the frontend, and will be calling your did.json from another domain.
  • Your Forever Domain
    • Can you trust the registrar holding your domain?
    • Can you afford to renew the domain?
    • Are you situated in the same country as the domain? Again, this is a worst-case scenario — but if you're planning to use this long-term, it's a good idea to stick with a domain associated with your own country, as discussed previously.

I won't go into detail on any of these points. If you're unsure about web hosting or domain purchasing, this isn't a road you should be going down. With that in mind: pick a tool, and take your time reading through the process carefully. It's generally a one-shot deal — if you mess it up, you risk burning your did:web, so it's worth trying on a test domain or subdomain first.

Once it's created and the final did.json is being served, there's still a few more steps:

  1. Login to Bluesky — or Witchsky, if you're cool 😎
  2. Head to Settings ➔ Account ➔ Handle ➔ I have my own domain
  3. Use the domain you assigned in your DID document
    Usually, this domain updates automatically via a PLC op, but that obviously can't happen here. If you ever need to change your handle in future, you'll need to set it in the alsoKnownAs array yourself — as the first item, though you can list extra handles in the array, Atproto doesn't handle them. Unless your PDS operator is willing to add custom endpoints or records, you also won't be able to use the PDS-provided domain suffix — something else that would normally happen automatically with a did:plc.
  4. Head to Profile — you should see your handle is now valid. At that point, try posting or interacting with something to verify it's working.

Et voila, you have a did:web!

But wait, what if you already have a well-established did:plc? This is something a lot of people contend with, as you generally learn about DID methods and did:web months after first onboarding to the Atmosphere — by which point it almost feels "too late" to use one. With an account that had over 4k followers, this is something I also contemplated for a while. As previously mentioned, DID method migrations are not possible. There is no one-size-fits-all, so here's what I did:

  1. Treat it as a "refresh". Many of these followers might not use Bluesky much, or even interact with my account much — some are likely just part of the initial surge you get when a new account is actively engaging a lot to establish a presence
  2. Let my followers know I was planning to move to another account, mentioning in the thread the technical details behind it, and pinning it. This new did:web account was an already-established alt account, so I wasn't worried about following a large amount of people and potentially being labelled as spam — something to think about.
  3. Begin to use the new did:web account and sever all interactions with my old did:plc account.
  4. Repost everything from my old did:plc account to my new did:web account, using a script I built. This meant people could scroll down my new account's timeline and still see content from my old account without having to head over to it.
  5. Migrate other records best I could with several other scripts I built. Some things to consider:
    1. Some Atproto apps don't treat the PDS as the source-of-truth (boo!). For example — at the time, things may be different now — deleting sh.tangled.* records didn't mirror over to Tangled, and creating pub.leaflet.* records didn't make them appear on Leaflet.
    2. Think about any references you may be breaking (reposts, likes, etc.).
    3. Be careful when moving records such as likes and reposts — this can cause unnecessary noise. To get around that, I copied these records in full, keeping the original interaction date intact, which meant Bluesky didn't trigger a notification for it. Test how other apps handle this too, if at all.
    4. Pay attention to rate limits and potential spam labels.
  6. Repost everything from my new did:web account to my old did:plc account, this time using another script that ran continuously. This allowed people who hadn't begun following my new account to still see my content — not everyone is terminally online.
  7. Pin a post on my old did:plc warning of a cut-off point where I will stop doing the above, and how to find me — a promise I stuck to.
  8. Make my old did:plc account obvious its now dead. A greyed out avatar, "(Old Account)" in the name, following only my new did:web, and changing the handle to @plc.ducky.ws.

I mentioned "scripts" a few times too. No, I'm not giving you the ones I wrote — they're long gone now. If you'd like help with anything I've mentioned here (it's very bespoke to my situation), don't hesitate to get in touch — I'm more than willing to help people through this process.

And, of course, if you just don't care about archival: pin a post, let people know what's happening, and move over to your new account. And if you're playing on hardcore mode — delete the old one.

did:web for the Developer

This is how this article began in the first place — it spawned from the very first post on my did:web. At the time, essentially everything was broken for did:web accounts, purely because apps were blindly knocking on plc.directory's door for information. Wrong!

Whether you're building an Atproto app, or you have one already existing that doesn't support did:web, here's what it takes to support us ~200 really quite cool people on the network:

  1. Resolve the handle to a DID — but this time you'll receive a did:web
    Psst! While you can use public.api.bsky.app to do this, avoid adding yet more reliance on PBC by resolving it entirely yourself: you just need a race condition that does the following:
    a) Look up _atproto.<handle>'s DNS TXT record
    b) Look up https://<handle>/.well-known/atproto-did
    Some people will host both, others only one — either way, whichever one returns first will give you the DID.
  2. Pull out the domain from the DID. For example, did:web:didd.uk becomes didd.uk — do not assume the domain is the same as the handle.
  3. Fetch the DID document from the well-known path, at https://<domain>/.well-known/did.json
  4. Consume the DID document as if it were a did:plc — everything is the same from here.

As an extra caveat, Atproto has the possibility to handle extra DID methods in the future. Don't blindly assume did:plc — be futureproof. Your code should look something like this:

async function resolveDid(did) {
  const didMethod = did.split(':')[1];
  const didId = did.split(':')[2];
  const plcDir = 'https://plc.directory';

  let url;
  switch (didMethod) {
    case 'plc':
      url = `${plcDir}/${did}`;
      break;

    case 'web':
      url = `https://${didId}/.well-known/did.json`;
      break;

    default:
      console.warn(`Unsupported DID method: ${did}`);
      return null;
  }

  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to resolve ${did} from ${url}`);
  return res.json();
}

And that's it. Easy, no? So easy, you won't even have to spend any tokens implementing did:web.

Wrap Up

And that's did:web — at least everything worth mentioning; the good, the bad, and the cursed. It's not for everyone, and it certainly doesn't mean all 46m users on the Atmosphere should jump over to one, but for those curious enough, I really hope I covered all grounds here. If you just want the juice with no hassle, did:plc is perfectly adequate, and hopefully its foibles will be rectified in the future. The fact both DID methods just work and exist in the background is a testament to how well Atproto is structured.

Some things I didn't mention:

  • DID document: Briefly touched on the important parts of the DID document earlier, but I never went into the rest of the structure, since this article was always meant to be a usability piece rather than a deep technical one. That's a topic for another time.
  • Disaster recovery: What happens when something goes wrong? Knock on wood, I haven't had to deal with anything going wrong. I stupidly don't have the private key that generated my current did:web, but I'm assured as long as I still have physical access to the PDS and the did.json, it's not a big issue.
  • Manual creation: I would love to explore this, but it's not something I've done successfully either.
  • PDS hosting: did:webs are often hosted by people already running a PDS, but there are those using did:webs on PDSs they don't control. PDS hosting is something covered by many, many articles; despite hosting my own, it's not something I'm interested in going into depth about.
  • PDS migrating: This is possible, but it's not something I've personally done. PDSmoover can help facilitate both did:plc and did:web migrations; you just need to change service.serviceEndpoint in the did.json to point to the new PDS.

Hope to see you around the network with a shiny did:web in hand. And if you do take the jump, come hang out on the did:web feed on Bluesky.