Skip to main content

What is Decentralized Identity?

AT Protocol uses a dual identity system combining cryptographic DIDs (Decentralized Identifiers) with human-readable handles. This allows users to have both a permanent, secure identity and a friendly display name.

Identity Components

DIDs (Decentralized Identifiers)

DIDs are the permanent, cryptographic identifiers for users in AT Protocol:
  • Format: did:method:identifier
  • Example: did:plc:ewvi7nxzyoun6zhxrhs64oiz
  • Permanent: DIDs don’t change, even if you switch servers
  • Self-sovereign: You control your DID through cryptographic keys

Handles

Handles are human-readable names that map to DIDs:
  • Format: Domain name (e.g., alice.bsky.social, bob.com)
  • Changeable: Can be updated without losing your identity
  • Verifiable: Proven through DNS or HTTPS
  • Portable: Can use your own domain

Why Two Identifiers?

  • DIDs provide permanence and cryptographic security
  • Handles provide usability and memorability
  • Together they enable both security and user-friendliness

Resolving Handles

The @atproto/identity package provides handle resolution:
import { HandleResolver } from '@atproto/identity'

const resolver = new HandleResolver({
  timeout: 3000,
  backupNameservers: ['8.8.8.8', '1.1.1.1']
})

// Resolve handle to DID
const handle = 'atproto.com'
const did = await resolver.resolve(handle)

console.log(did) // 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'
Handle resolution tries two methods:

1. DNS TXT Record

The preferred method using DNS:
# Query DNS TXT record
dig _atproto.alice.bsky.social TXT

# Returns:
_atproto.alice.bsky.social. 300 IN TXT "did=did:plc:abc123"

2. HTTPS Well-Known

Fallback method using HTTPS:
# HTTP request
GET https://alice.bsky.social/.well-known/atproto-did

# Returns:
did:plc:abc123

DID Resolution

DIDs resolve to DID Documents containing service endpoints and public keys:
import { DidResolver } from '@atproto/identity'

const resolver = new DidResolver({
  timeout: 3000,
  plcUrl: 'https://plc.directory'
})

const did = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'
const doc = await resolver.resolve(did)

console.log(doc)
Example 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:plc:ewvi7nxzyoun6zhxrhs64oiz",
  "alsoKnownAs": ["at://atproto.com"],
  "verificationMethod": [
    {
      "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto",
      "type": "Multikey",
      "controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
      "publicKeyMultibase": "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"
    }
  ],
  "service": [
    {
      "id": "#atproto_pds",
      "type": "AtprotoPersonalDataServer",
      "serviceEndpoint": "https://morel.us-east.host.bsky.network"
    }
  ]
}

DID Methods

AT Protocol supports two DID methods:

did:plc

PLC (Placeholder) is the primary method used in AT Protocol:
import { DidPlcResolver } from '@atproto/identity'

const resolver = new DidPlcResolver('https://plc.directory', 3000)
const doc = await resolver.resolve('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
Characteristics:
  • Centralized directory with plans for decentralization
  • Fast resolution
  • Supports key rotation and service updates
  • Used by default in Bluesky

did:web

Web DIDs use domain names:
import { DidWebResolver } from '@atproto/identity'

const resolver = new DidWebResolver(3000)
const doc = await resolver.resolve('did:web:example.com')
Characteristics:
  • Fully decentralized
  • Resolution via HTTPS to https://example.com/.well-known/did.json
  • Relies on domain ownership
  • Slower resolution than PLC

Extracting AT Protocol Data

The identity package provides helpers to extract AT Protocol-specific data from DID documents:
import { DidResolver } from '@atproto/identity'

const resolver = new DidResolver({
  plcUrl: 'https://plc.directory'
})

const did = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'

// Get AT Protocol-specific data
const data = await resolver.resolveAtprotoData(did)

console.log(data)
// {
//   did: 'did:plc:ewvi7nxzyoun6zhxrhs64oiz',
//   signingKey: 'did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto',
//   handle: 'atproto.com',
//   pds: 'https://morel.us-east.host.bsky.network'
// }

Identity Verification Flow

Verify that a handle matches its DID document:
import { DidResolver, HandleResolver } from '@atproto/identity'

const didRes = new DidResolver({})
const hdlRes = new HandleResolver({})

const handle = 'atproto.com'

// Step 1: Resolve handle to DID
const did = await hdlRes.resolve(handle)

if (!did) {
  throw new Error('Handle does not resolve to a DID')
}

console.log(`Handle resolves to: ${did}`)

// Step 2: Resolve DID to document
const doc = await didRes.resolve(did)

// Step 3: Extract AT Protocol data
const data = await didRes.resolveAtprotoData(did)

// Step 4: Verify handle matches
if (data.handle !== handle) {
  throw new Error('Handle mismatch! Potential impersonation.')
}

console.log('✓ Identity verified')
console.log('PDS:', data.pds)
console.log('Signing Key:', data.signingKey)

Caching

DID resolution can be expensive, so caching is important:
import { DidResolver, MemoryCache } from '@atproto/identity'

const cache = new MemoryCache()

const resolver = new DidResolver({
  didCache: cache,
  plcUrl: 'https://plc.directory'
})

// First resolution - hits network
const doc1 = await resolver.resolve(did)

// Second resolution - uses cache
const doc2 = await resolver.resolve(did)

// Force refresh from network
const doc3 = await resolver.resolve(did, true)
Cache Interface:
interface DidCache {
  cacheDid(
    did: string,
    doc: DidDocument,
    prevResult?: CacheResult
  ): Promise<void>
  
  checkCache(did: string): Promise<CacheResult | null>
  
  refreshCache(
    did: string,
    getDoc: () => Promise<DidDocument | null>,
    prevResult?: CacheResult
  ): Promise<void>
  
  clearEntry(did: string): Promise<void>
  clear(): Promise<void>
}

Using Your Own Domain

One of AT Protocol’s key features is the ability to use your own domain as your handle:

Option 1: DNS TXT Record

Add a TXT record to your domain:
_atproto.yourdomain.com IN TXT "did=did:plc:your-did-here"

Option 2: HTTPS Well-Known

Create a file at https://yourdomain.com/.well-known/atproto-did:
did:plc:your-did-here
Then update your handle in your client application to yourdomain.com.

Identity Portability

The separation of DID and handle enables powerful portability:
// You can change your handle...
await agent.com.atproto.identity.updateHandle({
  handle: 'alice.custom-domain.com'
})

// ...without losing your:
// - DID (permanent identifier)
// - Content (posts, likes, follows)
// - Social graph (followers follow your DID)
// - Reputation (cryptographically signed history)

Best Practices

DID resolution involves network requests. Always use caching in production to reduce latency and load.
Always verify that a resolved DID’s document contains the expected handle in alsoKnownAs to prevent impersonation.
Handle resolution can fail due to DNS issues or misconfiguration. Always have fallback logic.
Set reasonable timeouts for both handle and DID resolution to prevent hanging requests.

Error Handling

import { 
  PoorlyFormattedDidError, 
  UnsupportedDidMethodError 
} from '@atproto/identity'

try {
  const doc = await resolver.resolve('did:invalid:123')
} catch (error) {
  if (error instanceof PoorlyFormattedDidError) {
    console.error('Invalid DID format')
  } else if (error instanceof UnsupportedDidMethodError) {
    console.error('DID method not supported')
  } else {
    console.error('Resolution failed:', error)
  }
}

Additional Resources

@atproto/identity Package

NPM package documentation

DID Specification

W3C DID specification

PLC Directory

Public PLC DID directory

Identity Spec

AT Protocol identity specification

Build docs developers (and LLMs) love