ATProto in Practice #1: Identity
I’m starting a new blog post series that I’ve named “ATProto in Practice”. I want to go through some practical tasks that you’re likely to need when working with the protocol, using Ruby and my Ruby gems for the examples. These will (hopefully) be a bit shorter than my standard book-length blog posts here π«
If you’re new to the AT Protocol and you haven’t read my long Introduction to AT Protocol post that goes through the whole architecture and defines the various pieces of it, I recommend you read that one first, because I won’t be explaining everything from scratch again here.
Let’s start with something pretty fundamental: user identity.
DIDs & handles
Every account has an immutable identifier called a DID (Decentralized Identifier), e.g. did:plc:z72i7hdynmk6r22z27h6tvur. This ID serves a similar role as an UUID might in an SQL database β it’s used everywhere behind the scenes in the protocol for any references between accounts and records, in URIs and so on.
But generally every account also has a human-readable handle assigned to it, which can be changed at any time, and this handle is normally what you see in the UI, often in user-facing URLs, what you use to log in with, etc. The handle is a domain name, e.g. @python.org, and any existing domain name you own and use for your website can be used as your handle.
As you start building on ATProto, one of the first things you might need to do is convert between these two identifiers. For example, a user logs in to your app using a handle and you need to first “resolve” it to a DID, or a URI or other reference in a record points to a DID, but you want to show the corresponding user’s handle in the UI, and the handle is not included in the data you got.
A useful UI tool for quickly looking this up is internect.info. If you look up e.g. “firefox.com” there, you will get a page showing details of Firefox’s ATProto account: the DID (did:plc:m424cqoxwhxgjutbta7jmrur), when it was created, assigned PDS hostname, and so on.
Now, how to do the same thing in code?
Every DID has a so called DID Document, a JSON file storing some basic info about that identity, and among other things, that document includes the alsoKnownAs field, which lists the assigned handles, in the form of at:// URIs with only the first segment. Almost every account will have exactly one handle assigned, but it might happen that one will have zero or more than one. It might also happen that the array will contain some invalid strings which aren’t proper handles β e.g. don’t include the at:// prefix, or only contain one word and no periods. You need to filter only the valid ones.
The assignment is bi-directional β a DID has one (usually) handle assigned in its document, and a handle resolves to a DID. You should ideally check the assignment in both directions, because it could happen that the other sign of the assignment is no longer valid, or worse β has never been real. That is, when you resolve a handle to a DID, check if that DID has that handle configured, and if you check a DID’s assigned handles, check if those handles are verified (if they resolve to the same DID).
Resolving handles
When starting from the handle’s side, there are two ways that a handle can be verified (assigned to a DID), and you need to check for both, because the user might have used either method depending on what’s more convenient to them:
1) Verification by DNS
The first way to verify a handle is to add a DNS TXT record in your domain’s DNS config, with _atproto (with an underscore) as the subdomain name and did=... as the text. This method is generally more common for custom handles, when someone manually assigns some domain which they’ve used before for their site or other things (or that they bought specifically for this purpose) to their Bluesky/ATProto account.
In Ruby, you can read these DNS records this way:
require 'resolv'
records = Resolv::DNS.open { |dns|
dns.getresources('_atproto.eff.org', Resolv::DNS::Resource::IN::TXT)
}
# => [#<Resolv::DNS::Resource::...>]
records.first.data
# => "did=did:plc:lr36xv2l64jwtnyoaqem6z2z"
So to go from a handle to a DID you’ll need to get these TXT records, check if one exists, if the format looks as expected, and dig out the part after did=:
def resolve_handle_by_dns(domain)
dns_records = Resolv::DNS.open { |dns|
dns.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
}
if record = dns_records.first
if record.data =~ /\Adid\=(did\:\w+\:.+)\z/
return $1
end
end
return nil
end
2) Verification by HTTP .well-known
The second way is to put a text file with the DID inside in a special .well-known location on the given domain’s website, at .well-known/atproto-did (just the DID itself, without the did= prefix this time). This method is more common for default handles given out by PDSes, like *.bsky.social used by default when you sign up on Bluesky, or for larger organizations setting up handles for a group of their employees β because then you just need one catch-all domain, and you handle the request on the HTTP server level, returning different DIDs depending on the request’s hostname.
In Ruby, you can make that request for example like this:
require 'open-uri'
def resolve_handle_by_well_known(domain)
url = "https://#{domain}/.well-known/atproto-did"
text = URI.open(url).read
return text if text =~ /\Adid\:\w+\:.+\z/
rescue StandardError
# got a 404 or network/DNS error
nil
end
And as I mentioned, you need to check both methods (in parallel or sequentially), so the whole thing can look like this:
def resolve_handle(domain)
if dns_did = resolve_handle_by_dns(domain)
DID.new(dns_did, :dns)
elsif http_did = resolve_handle_by_well_known(domain)
DID.new(http_did, :http)
else
nil
end
end
If neither of the methods return anything, the handle is not verified (which is what shows up on Bluesky as “β Invalid Handle”, and in the APIs usually as "handle.invalid"). This is still a valid account and most things work on it as before, but you should not show the given domain as owned by that account, since that ownership cannot be confirmed; in most cases, it just means some temporary network problems or that the user has deleted something accidentally, and it will be resolved shortly, although it could also mean the account has been abandoned. AFAIK it’s unspecified what should happen if both methods return something and they point to different DIDs.
There is also a set of reserved TLDs that are not allowed to be used for ATProto handles β it makes sense to reject those immediately without running the check; the list currently includes: .alt, .arpa, .example, .internal, .invalid, .local, .localhost, and .onion.
Fun fact: the initial version of this had the path defined at /xrpc/com.atproto.identity.resolveHandle, but some people quickly figured out how to abuse it π
Resolving DIDs
To go in the other direction, you’ll need to load the DID document, or “resolve” a DID; and again, there are two possible methods here that you need to handle in your code β but importantly, you only use one depending on the type of the DID. There are two DID “methods” defined in ATProto: did:plc (originally “placeholder”, later retconned to mean “Public Ledger of Credentials” π) and did:web.
did:plc stores the DID document in a centralized database at plc.directory (Bluesky is in the process of moving the control over that service to a separate Swiss non-profit). This database accepts “operations” in a specific format submitted by the owner of the DID, and it also keeps a complete history of those operations, letting you view “audit logs” for each DID. Coincidentally, this also means that for any DID, you can see the past history of all handles that were assigned to this DID, and this information can’t be erased, which is a bit important to remember for privacy/OPSEC reasons.
did:web includes a domain name after the second colon, and stores the DID document on a server on that domain, under your control. This however has the pretty big downside that the DID is permanently bound to this domain and you have to keep it live in order to keep this account, and you can’t move the DID to another domain, ever. Which is why this method is not recommended by Bluesky for anyone who doesn’t know what they’re getting into, is not very easy to opt into (you need to use some command line tools to create such DID), and effectively very few people use it at all (IIRC there’s around half a thousand of such accounts at the moment).
Because of that, a lot of people building apps forget about adding support for did:web and people with such DIDs find that newly launched apps don’t work for them, until they complain and it gets fixed. But of course you won’t make that mistake π
did:web DIDs *do not* store a history of previous versions of the DID document anywhere and there’s no way to view past handles for one of those, unless of course someone recorded them independently.
Note: do not confuse the domain in the did:web with the domain used as a handle (or the PDS hostname)! These three are completely independent, and it’s entirely possible to have a DID like did:web:mydidweb.com, which uses a handle like @myhandle.org, and which is hosted at pds.server.com; there is usually at least some overlap here, but it’s not a given. For example, the account @atproto.fr is a did:web, with the DID domain did.atproto.fr, and PDS hosted at pds.atproto.fr.
For did:plc DIDs, the DID document is loaded from https://plc.directory/<did> (and you can get the audit log of past operations at https://plc.directory/<did>/log/audit). So you can load it like this:
require 'json'
require 'open-uri'
def resolve_did_plc(did)
url = "https://plc.directory/#{did}"
JSON.parse(URI.open(url).read)
end
For did:webs, the path is .well-known/did.json (do not confuse it with the path for validating handles mentioned above, which is .well-known/atproto-did on the domain of the handle). So the code will be:
def resolve_did_web(did)
domain = did.split(':')[2]
url = "https://#{domain}/.well-known/did.json"
JSON.parse(URI.open(url).read)
end
And together:
def resolve_did(did)
did_type = did.split(':')[1]
case did_type
when 'plc'
resolve_did_plc(did)
when 'web'
resolve_did_web(did)
else
raise "Unknown DID method: did:#{did_type}"
end
end
Parsing the DID document
The DID document JSON looks something like this:
{
"@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:oio4hkxaop4ao4wz2pp3f4cr",
"alsoKnownAs": [
"at://mackuba.eu" /* <- it me! */
],
"verificationMethod": [
{
"id": "did:plc:oio4hkxaop4ao4wz2pp3f4cr#atproto",
"type": "Multikey",
"controller": "did:plc:oio4hkxaop4ao4wz2pp3f4cr",
"publicKeyMultibase": "zQ3shMcFGXMMsEX5nxmV8QfBZQc1Uw6mSWADuKSgsvieu5ezC"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://lab.martianbase.net" /* my PDS */
}
]
}
In practice, the interesting parts are:
- the
alsoKnownAsfield, which lists the handles - and the
servicearray, from which you dig out the hostname of the PDS, looking for an entry withid == '#atproto_pds'and grabbing itsserviceEndpoint; this will be useful in some next episode, where we’ll be loading data from a given user’s PDS and we’ll first need to find where it is - you might also need the
verificationMethoddata if you implement any (server-side) authentication or cryptographic stuff manually (unlikely at first)
As mentioned before, the alsoKnownAs field might contain entries that aren’t real handles, so you’ll need to filter it to only those that start with at://, look like proper domains, aren’t from reserved TLDs, and then β if you’ve started the lookup from the DID β verify that the handles also point back to that DID, as we’ve talked about in the first section. If there are multiple potentially valid handles listed in that array, check them one by one and take the first one that validates correctly in the other direction. (You can skip the validation to save time in cases when it’s not important to really check if the handle is legit, e.g. if it only goes to some log files or debug output.)
RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
def get_verified_handle(did)
# get the document JSON using the function from above
did_doc = resolve_did(did)
handles = did_doc['alsoKnownAs'] || []
handles.select { |h|
# filter out non-atproto entries
h.start_with?('at://')
}.map { |h|
h.gsub('at://', '')
}.reject { |h|
# reject single-segment domains and reserved TLDs
!h.include?('.') || RESERVED_DOMAINS.include?(h.split('.').last)
}.detect { |h|
# get the first handle which correctly points back this DID
result = resolve_handle(h)
result && result.did == did
}
end
For other fields like service it’s also a good idea to double-check that everything looks right, because people put some truly weird stuff in their DID documents sometimesβ¦ the thing is, plc.directory only really checks the rough shape of the JSON that’s submitted to it, but doesn’t do detailed checks on the data in each field. This is probably doubly true for did:webs, where nobody can prevents you from putting anything in there.
Using DIDKit
Of course, if you use any remotely popular programming language, there is usually some library that can handle this for you, so you should not need to do all of this by hand. I maintain a website called sdk.blue which lists over a hundred libraries and SDKs for ATProto, grouped by language and ordered by popularity. Whatever you code in, you’ll likely find something that you can use there.
For Ruby, I have a number of gems listed there I’ve built over the past 3 years. The one used for things I talk about in this blog post is called “DIDKit”.
The basic usage looks this way:
require 'didkit'
# resolving various handles:
did = DID.resolve_handle('rude1.blacksky.team')
# => #<DIDKit::DID:... @did="did:plc:w4xbfzo7kqfes5zb7r6qv3rw", @type=:plc, @resolved_by=:http>
did = DID.resolve_handle('atproto.fr')
# => #<DIDKit::DID:... @did="did:web:did.atproto.fr", @type=:web, @resolved_by=:dns>
did = DID.resolve_handle('jimray.bsky.team')
# => #<DIDKit::DID:... @did="did:plc:lysqukqdu6hsrhet5v2brjgo", @type=:plc, @resolved_by=:dns>
# getting info from a DID doc:
did.document
# => #<DIDKit::Document:...>
did.document.pds_host
# => "calocybe.us-west.host.bsky.network"
did.document.handles
# => ["jimray.bsky.team"]
# verifying a handle found in a DID doc:
did = DID.new('did:web:did.atproto.fr')
did.get_verified_handle
# => "atproto.fr"
PLC Directory export
If you want to get the DID β handle or DID β PDS mappings for a very large number of accounts, e.g. in order to generate some statistics or to have some kind of local cache for quicker resolving, plc.directory has an /export API where you can fetch 1000 operations starting from a given timestamp at a time (and then iterate in a loop over next pages). The format is “JSON lines”, i.e. 1000 lines with separate JSON object on each line. See e.g.:
curl "https://plc.directory/export?after=2026-06-01T00:00:00Z" | less
(You may notice the large amount of spam, mainly of two kinds β the accounts on a pds.trump.com PDS, which doesn’t even exist, and those padding the alsoKnownAs with some long strings of random or binary data. Unfortunately, this is a bit of an unsolved problem right now, though it’s fairly easy to filter these out.)
Each line is an “operation”, which includes the account’s DID, operation timestamp, and operation data, which is the fields that you can also see in the most current version of the DID document. Note that, annoyingly, the service(s) section has a completely different format in the operations JSON than in the DID documentβ¦ object/map vs. array, and pluralized field name:
{
"did": "did:plc:oio4hkxaop4ao4wz2pp3f4cr",
"operation": {
"sig": "djM-yd6FpnXKPGnCUIWNBpLoO75dPMekc3swuo8JIW8_QgQydj3LI88WakzHtcTmgrkRsOHHaq9x4VyhZQC5Ow",
"prev": "bafyreifdjetsl2goygbcdv6h4cnpgvpvddzgeby32eifaxnq73znghnqhq",
"type": "plc_operation",
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://lab.martianbase.net"
}
},
"alsoKnownAs": [
"at://mackuba.eu"
],
"rotationKeys": [
"did:key:zQ3shbnHV9jPtFjA4Dvnx5PFPXcqPe5tQx4e8ZfnHjeMMqbyX"
],
"verificationMethods": {
"atproto": "did:key:zQ3shMcFGXMMsEX5nxmV8QfBZQc1Uw6mSWADuKSgsvieu5ezC"
}
},
"cid": "bafyreiemhdy4rxurlnaistej7pzgctlanfwpz7dneqxin34rxcwipavozu",
"nullified": false,
"createdAt": "2025-01-24T20:32:44.215Z"
}
Normal operations that update the handle or PDS are of the plc_operation type. There is also a tombstone type, which means a DID is being deleted, and there’s also a legacy create format used in some old operations from 2022/23 β if you process very old data, you’ll also need to be able to parse that one (it has a different format again for how it lists the handle & PDS).
In DIDKit, you can use the PLCImporter class to fetch & process data from the PLC directory:
require 'didkit'
importer = DIDKit::PLCImporter.new(since: last_date)
importer.fetch do |ops|
puts "Fetched #{ops.length} records until #{importer.last_date}"
ops.each do |op|
case op.type
when :plc_operation, :create
update_handle_info(op.did, op.handles, op.pds_endpoint)
when :plc_tombstone
delete_handle_info(op.did)
end
end
end
Although small heads-up β this class is pending a rewrite at some point in order to support a newer version of the API (you can now iterate using a seq cursor instead of the timestamp, which is a bit cleaner), so the interface here will change.
Also, if you want to get all PLC data from the beginning (late 2022), that’s somewhere on the order of 100 GB total data right now, and it takes a while to download page by page β but you can ask around on the skyline, and some ATProto developers might have a recent full dump of the PLC on a local disk that they can share as a single file download if you want.
And of course any of the plc.directory export data will not include did:web accounts, since they aren’t stored there at all. There’s also no comparable mechanism for fetching all did:web accounts info or their update history β the way you update a did:web is that you just put an updated JSON on the .well-known path on the website, and make the PDS emit an #identity update event on the firehose. So if you want to track those, you’ll need to listen to the firehose for those events (wait for a later episode for that :). A good way to find did:webs is also to look at post records and look for posts made from an account that is a did:web that you don’t recognize yet.
That’s all for today! Some links to spec documents if you want more specific details:
- “Identity” on atproto.com
- “DID” on atproto.com
- “Handle” on atproto.com
- did:plc specification
- plc.directory API
In the next episodes, we’ll look at things like making requests to the PDSes and AppView, and at streaming data from the firehose.

π¬ You can add a comment here by replying to the post on Bluesky or Mastodon.