···1+---
2+title: Where It's at://
3+date: '2025-10-02'
4+spoiler: From handles to hosting.
5+---
6+7+You might have heard about the AT protocol (if not, [read this!](/open-social/))
8+9+Together, all servers speaking the AT protocol comprise *the atmosphere*--a web of hyperlinked JSON. Each piece of JSON on the atmosphere has its own `at://` URI:
10+11+- <span style={{wordBreak: 'break-word'}}>[`at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z`](https://pdsls.dev/at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z)</span>
12+- <span style={{wordBreak: 'break-word'}}>[`at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22`](https://pdsls.dev/at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22)</span>
13+- <span style={{wordBreak: 'break-word'}}>[`at://tessa.germnetwork.com/pub.leaflet.publication/3lzz6juivnc2d`](https://pdsls.dev/at://tessa.germnetwork.com/pub.leaflet.publication/3lzz6juivnc2d)</span>
14+15+But where do they point, exactly?
16+17+Given an `at://` URI, how do you locate the corresponding JSON?
18+19+In this post, I'll show you the exact process of resolving an `at://` URI step by step. Turns out, this is also a great way to learn the details of how `at://` works.
20+21+Let's start with the structure of a URI itself.
22+23+---
24+25+### The User as the Authority
26+27+As you might know, a URI often contains a scheme (for example, `https://`), an *authority* (like `wikipedia.com`), a path (like `/Main_Page`), and maybe a query.
28+29+In most protocols, including `https://`, the authority part points at whoever's *hosting* the data. Whoever *created* this data is either not present, or is in the path:
30+31+
32+33+**The `at://` protocol flips that around.**
34+35+In `at://` URIs, whoever *created* the data is the authority, in the most literal sense:
36+37+
38+39+**The user is the authority for their own data.** Whoever's *hosting* the data could change over time, and is *not* directly included in an `at://` URI. To find out the actual physical server hosting that JSON, you're gonna need to take a few steps.
40+41+---
42+43+### A Post in the Atmosphere
44+45+Let's try to resolve this `at://` URI to the piece of JSON it represents:
46+47+
48+49+An easy way to resolve an `at://` URI is using an [SDK](https://sdk.blue/) or a client app. Let's try an online client, for example, [pdsls](https://pdsls.dev/at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z) or [Taproot](https://atproto.at/viewer?uri=at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z) or [atproto-browser](https://atproto-browser.vercel.app/at/ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z). They'll figure out the physical server where its JSON is currently hosted, and show that JSON for you.
50+51+**The above `at://` URI points at this JSON, wherever it is currently being hosted:**
52+53+```js
54+{
55+ "uri": "at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z",
56+ "cid": "bafyreiae4ehmkk4rtajs5ncagjhrsv6rj3v6fggphlbpyfco4dzddp42nu",
57+ "value": {
58+ "text": "posting from did:web, like a boss",
59+ "$type": "app.bsky.feed.post",
60+ "langs": ["en"],
61+ "createdAt": "2025-09-29T12:53:23.048Z"
62+ }
63+}
64+```
65+66+You can guess by the `$type` field being `"app.bsky.feed.post"` that this is some kind of a post (which might explain why it has fields like `text` and `langs`).
67+68+However, note that this piece of JSON represents a certain social media post *itself*, not a web page or a piece of some app. **It's pure data as a piece of JSON**, not a piece of UI. You may think of the `$type` stating the data *format*; the `app.bsky.*` prefix tells us that the `bsky.app` application might know something about what to do with it. Other applications [may also](https://bsky.app/profile/o.simardcasanova.net/post/3luujudlr5c2j) consume and produce data in this format.
69+70+A careful reader might notice that the `uri` in the JSON block is *also* an `at://` URI but it's slightly different from the original `at://` URI we requested:
71+72+```js
73+// What's at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z ?
74+{
75+ "uri": "at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z",
76+ // ...
77+}
78+```
79+80+In particular, the short `ruuuuu.de` authority has expanded into a longer `did:web:iam.ruuuuu.de` authority. Maybe that's the physical host?
81+82+**Actually, no, that's not the physical host either**--it's something called an *identity*. Turns out, resolving an `at://` URI is done in three distinct steps:
83+84+1. Resolve the handle to an identity *("who are you?”)*
85+2. Resolve that identity to a hosting *("who holds your data?”)*
86+3. Request the JSON from that hosting *("what is the data?”)*
87+88+Let's go through each of these steps and see how they work.
89+90+---
91+92+### From Handles to Identities
93+94+The `at://` URIs you've seen earlier are fragile because they use handles.
95+96+Here, `ruuuuu.de`, `danabra.mov`, and `tessa.germnetwork.com` are handles:
97+98+- <span style={{wordBreak: 'break-word'}}>[`at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z`](https://pdsls.dev/at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z) </span>
99+- <span style={{wordBreak: 'break-word'}}>[`at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22`](https://pdsls.dev/at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22)</span>
100+- <span style={{wordBreak: 'break-word'}}>[`at://tessa.germnetwork.com/pub.leaflet.publication/3lzz6juivnc2d`](https://pdsls.dev/at://tessa.germnetwork.com/pub.leaflet.publication/3lzz6juivnc2d)</span>
101+102+*(Read more about [domains as "internet handles" here.](/open-social/#open-social))*
103+104+The user may choose to change their `at://` handle later, and it is important for that not to break any links between pieces of JSON already on the network.
105+106+This is why, before you *store* an `at://` URI, you should turn it into a canonical form by resolving the handle to something that never changes--an *identity*. An identity is like an account ID, but global and meant for the entire web. There are two mechanisms to resolve a handle to an identity (also known as a “[DID](https://en.wikipedia.org/wiki/Decentralized_identifier)”):
107+108+1. Query the DNS TXT record at `_atproto.<handle>` looking for `did=???`
109+2. Make an HTTPS GET to `https://<handle>/.well-known/atproto-did`
110+111+The thing you're looking for, the DID, is going to have a shape like `did:something:whatever`. (We'll revisit what that means later.)
112+113+---
114+115+For example, let's try to resolve `ruuuuu.de` via the DNS mechanism:
116+117+```sh {6}
118+$ nslookup -type=TXT _atproto.ruuuuu.de
119+Server: 192.168.1.254
120+Address: 192.168.1.254#53
121+122+Non-authoritative answer:
123+_atproto.ruuuuu.de text = "did=did:web:iam.ruuuuu.de"
124+```
125+126+Found it!
127+128+The `ruuuuu.de` handle *claims* to be owned by `did:web:iam.ruuuuu.de`, whoever that may be. That's all that we wanted to know at this point:
129+130+
131+132+**Note this doesn't *prove* their association yet.** We'll need to verify that whoever controls the `did:web:iam.ruuuuu.de` identity "agrees" with `ruuuuu.de` being their handle. The mapping is bidirectional. But we'll confirm that in a later step.
133+134+---
135+136+Now let's try to resolve `danabra.mov` using the DNS route:
137+138+```sh {6}
139+$ nslookup -type=TXT _atproto.danabra.mov
140+Server: 192.168.1.254
141+Address: 192.168.1.254#53
142+143+Non-authoritative answer:
144+_atproto.danabra.mov text = "did=did:plc:fpruhuo22xkm5o7ttr2ktxdo"
145+```
146+147+That also worked! The `danabra.mov` handle claims to be owned by the `did:plc:fpruhuo22xkm5o7ttr2ktxdo` identity, whoever that may be:
148+149+
150+151+This DID looks a bit different than what you saw earlier but it's also a valid DID. Again, it's important to emphasize we've not confirmed the association yet.
152+153+---
154+155+Subdomains like `barackobama.bsky.social` can also be handles.
156+157+Let's try to resolve it:
158+159+```sh {6}
160+$ nslookup -type=TXT _atproto.barackobama.bsky.social
161+Server: 192.168.1.254
162+Address: 192.168.1.254#53
163+164+Non-authoritative answer:
165+*** Can't find _atproto.barackobama.bsky.social: No answer
166+```
167+168+The DNS mechanism didn't work, so let's try with HTTPS:
169+170+```sh {2}
171+$ curl https://barackobama.bsky.social/.well-known/atproto-did
172+did:plc:5c6cw3veuqruljoy5ahzerfx
173+```
174+175+That worked! This means that `barackobama.bsky.social` handle claims to be owned by the `did:plc:5c6cw3veuqruljoy5ahzerfx` identity, whoever that is:
176+177+
178+179+So you get the idea. When you see a handle, you can probe it with DNS and HTTPS to see if it claims to be owned by some identity (a DID). If you found a DID, you'll then be able to (1) verify it actually owns that handle, and (2) locate the server that hosts the data for that DID. And that will be the server you'll ask for the JSON.
180+181+[In practice](https://docs.bsky.app/docs/advanced-guides/resolving-identities), if you're building with AT, you'll likely want to either deploy your own handle/did resolution cache or hit an existing one. (Here's [one implementation.](https://ngerakines.leaflet.pub/3lyea5xnhhc2w))
182+183+---
184+185+### AT Permalinks
186+187+Now you know how handles resolve to identities, also known as DIDs. Unlike handles, which change over time, DIDs never change--they're immutable.
188+189+These `at://` links, which use handles, are human-readable but fragile:
190+191+- <span style={{wordBreak: 'break-word'}}>[`at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z`](https://pdsls.dev/at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z) </span>
192+- <span style={{wordBreak: 'break-word'}}>[`at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22`](https://pdsls.dev/at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22)</span>
193+- <span style={{wordBreak: 'break-word'}}>[`at://tessa.germnetwork.com/pub.leaflet.publication/3lzz6juivnc2d`](https://pdsls.dev/at://tessa.germnetwork.com/pub.leaflet.publication/3lzz6juivnc2d)</span>
194+195+They will break if one of us changes a handle again.
196+197+In contrast, the `at://` links below, which use DIDs, will not break until we either delete our accounts, delete these records, or permanently take down our hosting:
198+199+- <span style={{wordBreak: 'break-word'}}>[`at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z`](https://pdsls.dev/at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z) </span>
200+- <span style={{wordBreak: 'break-word'}}>[`at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/sh.tangled.feed.star/3m23ddgjpgn22`](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/sh.tangled.feed.star/3m23ddgjpgn22)</span>
201+- <span style={{wordBreak: 'break-word'}}>[`at://did:plc:ad4m72ykh2evfdqen3qowxmg/pub.leaflet.publication/3lzz6juivnc2d`](https://pdsls.dev/at://did:plc:ad4m72ykh2evfdqen3qowxmg/pub.leaflet.publication/3lzz6juivnc2d)</span>
202+203+So, really, this is the "true form" of an `at://` URI:
204+205+
206+207+**Think of `at://` links with DIDs as "permalinks".** Any application *storing* `at://` URIs should store them in this canonical form so that logical links between our pieces of JSON don't break when we change our handles or change our hosting.
208+209+Now that you know how to resolve a handle to a DID, you want to do two things:
210+211+1. Verify that whoever owns this DID actually goes by that handle.
212+2. Find the server that hosts all the data for this DID.
213+214+You can do both of these things by fetching a piece of JSON called the *DID Document*. You can think of it as sort of a "passport" for a given DID.
215+216+How you do that depends on what kind of DID it is.
217+218+---
219+220+### From Identities to Hosting
221+222+Currently, there are two kinds of DIDs, known as *DID methods*, supported by the AT protocol: `did:web` (a W3C standard) and `did:plc` ([specified](https://github.com/did-method-plc/did-method-plc) by Bluesky).
223+224+Let's compare them.
225+226+#### `did:web`
227+228+The `ruuuuu.de` handle claims to be owned by `did:web:iam.ruuuuu.de`:
229+230+
231+232+To check this claim, let's find the DID Document for `did:web:iam.ruuuuu.de`. The [`did:web` method](https://w3c-ccg.github.io/did-method-web/) is a specification that specifies an [algorithm](https://w3c-ccg.github.io/did-method-web/#read-resolve) for that.
233+234+In short, you cut off the `did:web:` from the DID, append `/.well-known/did.json` to the end, and run an HTTPS GET request:
235+236+```sh
237+$ curl https://iam.ruuuuu.de/.well-known/did.json | jq
238+{
239+ "@context": [
240+ "https://www.w3.org/ns/did/v1",
241+ "https://w3id.org/security/multikey/v1",
242+ "https://w3id.org/security/suites/secp256k1-2019/v1"
243+ ],
244+ "id": "did:web:iam.ruuuuu.de",
245+ "alsoKnownAs": [
246+ "at://ruuuuu.de"
247+ ],
248+ "verificationMethod": [
249+ {
250+ "id": "did:web:iam.ruuuuu.de#atproto",
251+ "type": "Multikey",
252+ "controller": "did:web:iam.ruuuuu.de",
253+ "publicKeyMultibase": "zQ3shWHtz9QMJevcGBcffZBBqBfPo55jJQaVDuEG7ZwerALGk"
254+ }
255+ ],
256+ "service": [
257+ {
258+ "id": "#atproto_pds",
259+ "type": "AtprotoPersonalDataServer",
260+ "serviceEndpoint": "https://blacksky.app"
261+ }
262+ ]
263+}
264+```
265+266+This DID Document looks sleep-inducing but it tells us three important things:
267+268+- **How to refer to them.** The `alsoKnownAs` field confirms that whoever controls `did:web:iam.ruuuuu.de` indeed wants to use `@ruuuuu.de` as a handle. ✅
269+- **How to verify the integrity of their data.** The `publicKeyMultibase` field tells us the public key with which all changes to their data are signed.
270+- **Where their data is stored.** The `serviceEndpoint` field tells us the actual server with their data. Rudy's data is currently hosted at `https://blacksky.app`.
271+272+A DID Document really *is* like an internet passport for an identity: here's their handle, here's their signature, and here's their location. It connects a handle to a hosting while letting the identity owner change *either* the handle *or* the hosting.
273+274+
275+276+Users who interact with `@ruuuuu.de` on different apps in the atmosphere don't need to know or care about his DID *or* about his current hosting (and whether it moves). From their perspective, his current handle is the only relevant identifier. As for developers, they'll refer to him by DID, which conveniently never changes.
277+278+All of this sounds great, but there is one big downside to the `did:web` identity. If `did:web:iam.ruuuuu.de` ever loses control of the `iam.ruuuuu.de` domain, he will lose control over his DID Document, and thus over his entire identity.
279+280+Let's have a look at an alternative to `did:web` that avoids this problem.
281+282+#### `did:plc`
283+284+We already know the `danabra.mov` handle claims to be owned by the `did:plc:fpruhuo22xkm5o7ttr2ktxdo` identity (actually, that's me!)
285+286+
287+288+To check this claim, let's find the DID Document for `did:plc:fpruhuo22xkm5o7ttr2ktxdo`.
289+290+The [`did:plc` method](https://github.com/did-method-plc/did-method-plc) is a specification that specifies an [algorithm](https://github.com/did-method-plc/did-method-plc?tab=readme-ov-file#did-resolution) for that.
291+292+Essentially, you need to hit the [`https://plc.directory`](https://plc.directory) service with a `GET`:
293+294+```sh
295+$ curl https://plc.directory/did:plc:fpruhuo22xkm5o7ttr2ktxdo | jq
296+297+{
298+ "@context": [
299+ "https://www.w3.org/ns/did/v1",
300+ "https://w3id.org/security/multikey/v1",
301+ "https://w3id.org/security/suites/secp256k1-2019/v1"
302+ ],
303+ "id": "did:plc:fpruhuo22xkm5o7ttr2ktxdo",
304+ "alsoKnownAs": ["at://danabra.mov"],
305+ "verificationMethod": [
306+ {
307+ "id": "did:plc:fpruhuo22xkm5o7ttr2ktxdo#atproto",
308+ "type": "Multikey",
309+ "controller": "did:plc:fpruhuo22xkm5o7ttr2ktxdo",
310+ "publicKeyMultibase": "zQ3shopLMtAvvVrSsmWPE2pstFWY4xhGFBjkdRuETieUBozgo"
311+ }
312+ ],
313+ "service": [
314+ {
315+ "id": "#atproto_pds",
316+ "type": "AtprotoPersonalDataServer",
317+ "serviceEndpoint": "https://morel.us-east.host.bsky.network"
318+ }
319+ ]
320+}
321+```
322+323+The DID Document itself works exactly the same way. It specifies:
324+325+- **How to refer to me.** The `alsoKnownAs` field confirms that whoever controls `did:plc:fpruhuo22xkm5o7ttr2ktxdo` uses `@danabra.mov` as a handle. ✅
326+- **How to verify the integrity of my data.** The `publicKeyMultibase` field tells us the public key with which all changes to my data are signed.
327+- **Where my data is stored.** The `serviceEndpoint` field tells us the actual server with my data. It's currently at `https://morel.us-east.host.bsky.network`.
328+329+Let's visualize this:
330+331+
332+333+Although my handle is `@danabra.mov`, the actual server storing my data is currently `https://morel.us-east.host.bsky.network`. I'm happy to keep hosting it there but I'm thinking of moving it to a host I control in the future. I can change both my handle and my hosting without disruption to my social apps.
334+335+Unlike Rudy, who has a `did:web` identity, I stuck with `did:plc` (which is the default one when you create an account on Bluesky) so that I'm not irrecovably tying myself to any web domain. "PLC" officially stands for a "Public Ledger of Credentials"--essentially, it is like an npm registry but for DID Documents. (Fun fact: originally PLC meant "placeholder" but they've decided [it's a good tradeoff.](https://www.youtube.com/watch?v=m9AVUAUDC2A))
336+337+The upside of a `did:plc` identity is that I can't lose my identity if I forget to renew a domain, or if something bad happens at the top level to my TLD.
338+339+The downside of a `did:plc` identity is that whoever operates the PLC registry has some degree of control over my identity. They can't outright *change* it because every version is recursively signed with the hash of the previous version, every past version is queryable, and the hash of the initial version itself *is* the DID itself.
340+341+However, in theory, whoever operates the PLC registry [could](https://github.com/did-method-plc/did-method-plc?tab=readme-ov-file#plc-server-trust-model) deny my requests to update the DID Document, or refuse to serve some information about it. Bluesky is currently moving PLC to [an independent legal entity in Switzerland](https://docs.bsky.app/blog/plc-directory-org) to address some of these concerns. The AT community is also [thinking](https://updates.microcosm.blue/3lz7nwvh4zc2u) and [experimenting](https://plc.wtf/).
342+343+---
344+345+### From Hosting to JSON
346+347+So far, you've learned how to:
348+349+* Resolve a handle to a DID.
350+* Grab the DID Document for that DID.
351+352+That actually tells you enough to get the JSON by its `at://` URI!
353+354+Each DID Document includes the `serviceEndpoint` which is the actual hosting. *That's* the service you can hit by HTTPS to grab any JSON record it stores.
355+356+For example, the `@ruuuuu.de` handle resolves to `did:web:iam.ruuuuu.de`, and its DID Document has a `serviceEndpoint` pointing at `https://blacksky.app`.
357+358+To get the [`at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z`](https://pdsls.dev/at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z) record, hit the `https://blacksky.app` server with the [`com.atproto.repo.getRecord`](https://docs.bsky.app/docs/api/com-atproto-repo-get-record) endpoint, passing different parts of the `at://` URI as parameters:
359+360+```sh
361+$ curl "https://blacksky.app/xrpc/com.atproto.repo.getRecord?\
362+repo=ruuuuu.de&collection=app.bsky.feed.post&rkey=3lzy2ji4nms2z" | jq
363+```
364+365+And there it is:
366+367+```json
368+{
369+ "uri": "at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z",
370+ "cid": "bafyreiae4ehmkk4rtajs5ncagjhrsv6rj3v6fggphlbpyfco4dzddp42nu",
371+ "value": {
372+ "text": "posting from did:web, like a boss",
373+ "$type": "app.bsky.feed.post",
374+ "langs": [
375+ "en"
376+ ],
377+ "createdAt": "2025-09-29T12:53:23.048Z"
378+ }
379+}
380+```
381+382+Now let's get [`at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22`](https://pdsls.dev/at://danabra.mov/sh.tangled.feed.star/3m23ddgjpgn22):
383+384+- The `@danabra.mov` handle resolves to `did:plc:fpruhuo22xkm5o7ttr2ktxdo`.
385+- The DID Document for `did:plc:fpruhuo22xkm5o7ttr2ktxdo` points at `https://morel.us-east.host.bsky.network` as the current hosting.
386+387+Let's hit it:
388+389+```sh
390+$ curl "https://morel.us-east.host.bsky.network/xrpc/com.atproto.repo.getRecord?\
391+repo=danabra.mov&collection=sh.tangled.feed.star&rkey=3m23ddgjpgn22" | jq
392+```
393+394+And there you have it:
395+396+```json
397+{
398+ "uri": "at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/sh.tangled.feed.star/3m23ddgjpgn22",
399+ "cid": "bafyreiaghm4ep5eeqx6yf55z43ge65qswwis7aiwc67rt7ni54jj6pg6fa",
400+ "value": {
401+ "$type": "sh.tangled.feed.star",
402+ "subject": "at://did:plc:dzmqinfp7efnofbqg5npjmth/sh.tangled.repo/3m232u6xrq222",
403+ "createdAt": "2025-09-30T20:09:02Z"
404+ }
405+}
406+```
407+408+And that's how you resolve an `at://` URI.
409+410+---
411+412+### In Conclusion
413+414+To resolve an arbitrary `at://` URI, you need to follow three steps:
415+416+1. Resolve the handle to an identity (using DNS and/or HTTPS).
417+2. Resolve that identity to a hosting (using the DID Document).
418+3. Request the JSON from that hosting (by hitting it with `getRecord`).
419+420+If you're building a client app or a small project, an [SDK](https://sdk.blue/) will handle all of this for you. However, for good performance, you'll want to hit a resolution cache instead of doing DNS/HTTPS lookups on every request. [QuickDID](https://quickdid.smokesignal.tools/) is one such cache. You can also check out the [pdsls source](https://tangled.org/@pdsls.dev/pdsls/blob/main/src/utils/api.ts) to see how exactly it handles resolution.
421+422+The AT protocol is fundamentally an abstraction over HTTP, DNS, and JSON. But by standardizing how these pieces fit together—putting the user in the authority position, separating identity from hosting, and making data portable—it turns the web into a place where [your content belongs to you](/open-social/), not to the apps that display it.
423+424+There's more to explore in the atmosphere, but now you know where it's `at://`.