Gd.
Guides
Six tasks, each done twice: once from the terminal to find out whether it works, once in code to put it in a service.
§ 01
Resolve an identity
An AT Protocol identity has two names: a handle, which is a domain and can change, and a DID, which is permanent. Resolution goes handle → DID, and the DID document tells you where the account’s PDS lives and which key signs its commits.
resolve_verified accepts either form. Given a handle it queries DNS TXT at _atproto.<handle> and the HTTPS endpoint /.well-known/atproto-did and requires the two to agree, fetches the DID document from the PLC directory or the did:web host, and refuses it unless the document names the handle back. Given a DID it skips the first step. resolve_subject is the first step on its own.
cargo run --features clap,hickory-dns \
--bin atpdid -- resolve alice.bsky.social
use std::sync::Arc;
use std::time::Duration;
use atproto_identity::config::DEFAULT_PLC_HOSTNAME;
use atproto_identity::redirect::validated_policy;
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Whoever supplied the handle names the hosts this client reaches:
// check every redirect hop, and bound every request.
let http_client = reqwest::Client::builder()
.redirect(validated_policy())
.timeout(Duration::from_secs(10))
.build()?;
let resolver = InnerIdentityResolver {
// Pass nameservers explicitly, or an empty slice for the system set.
dns_resolver: Arc::new(HickoryDnsResolver::create_resolver(&[])),
http_client,
plc_hostname: DEFAULT_PLC_HOSTNAME.to_string(),
};
// Handle to DID to document, and the document must name the handle back.
let document = resolver.resolve_verified("alice.bsky.social").await?;
println!("{} — PDS {:?}", document.id, document.pds_endpoints_validated());
Ok(())
}
Two sources, one answer
DNS and the well-known endpoint can disagree, and during a handle migration they routinely do. The resolver detects the conflict rather than silently preferring one; decide deliberately which your application trusts.
§ 02
Read a repository archive
A repository export is a CAR v1 file: a root CID followed by a bag of content-addressed blocks. Inside those blocks is a signed commit pointing at a Merkle Search Tree, and the tree’s leaves are the record CIDs.
Reading is streaming: CarReader hands you one block at a time, so a large export does not have to fit in memory.
cargo run --package atproto-repo --features clap \
--bin atproto-repo-car -- ls repo.car
cargo run --package atproto-repo --features clap \
--bin atproto-repo-mst -- ls repo.car
use atproto_repo::CarReader;
use tokio::fs::File;
async fn read_car() -> anyhow::Result<()> {
let file = File::open("repository.car").await?;
let mut reader = CarReader::new(file).await?;
// The roots are the commit CIDs this archive claims to carry.
println!("roots: {:?}", reader.roots());
while let Some(block) = reader.next_block().await? {
println!("{} — {} bytes", block.cid, block.data.len());
}
Ok(())
}
To get an export in the first place, ask the account’s PDS for it. com.atproto.sync.getRepo streams the CAR, and ?since=<rev> narrows it to the blocks added after a revision you already hold.
§ 03
Sign a record
Attestation is CID-first. The record is prepared with $sig metadata, serialised to deterministic DAG-CBOR, hashed into a CID, and the CID bytes are what get signed. That makes the signing payload independent of JSON key order, whitespace, and every other thing that would otherwise make two encodings of the same record produce two different signatures.
use atproto_attestation::{create_inline_attestation, AnyInput};
use atproto_identity::key::{generate_key, KeyType};
use serde_json::json;
fn main() -> anyhow::Result<()> {
let key = generate_key(KeyType::P256Private)?;
let record = json!({
"$type": "app.bsky.feed.post",
"text": "Hello AT Protocol!",
"createdAt": "2024-01-01T00:00:00.000Z"
});
let metadata = json!({
"$type": "com.example.inlineSignature",
"key": "did:key:...",
"issuer": "did:plc:issuer123",
"issuedAt": "2024-01-01T00:00:00.000Z"
});
// The repository DID is bound into $sig. This is the replay defence.
let signed = create_inline_attestation(
AnyInput::Serialize(record),
AnyInput::Serialize(metadata),
"did:plc:repo123",
&key,
)?;
println!("{}", serde_json::to_string_pretty(&signed)?);
Ok(())
}
Low-S normalisation
ECDSA signatures come in pairs: for every valid (r, s) there is an equally valid (r, n − s). Without normalisation an attacker can flip one into the other and produce a second, different, still-valid signature over the same record. This crate normalises to low-S on the way out. If you verify these signatures elsewhere, make sure that verifier does too.
cargo run --package atproto-attestation --features clap,tokio \
--bin atproto-attestation-sign -- \
inline record.json did:plc:repo123 signing.key metadata.json \
> signed_record.json
cargo run --package atproto-attestation --features clap,tokio \
--bin atproto-attestation-verify -- signed_record.json did:plc:repo123
§ 04
Complete an OAuth flow
AT Protocol OAuth is OAuth 2.1 with three additions that are not optional: the authorization request is pushed server-to-server first (PAR, RFC 9126), the code exchange is PKCE-protected (RFC 7636), and every token is bound to a key you hold (DPoP, RFC 9449). All three live in atproto-oauth, module workflow.
Your client authenticates with a signed assertion rather than a shared string. OAuthClient holds a private_signing_key_data, not a client secret, and a second key — the DPoP key — is passed separately and binds the tokens you receive. That makes the workflow confidential-client-only: a browser or native app cannot keep the key from its user, so it talks to a backend that runs the flow.
use atproto_identity::key::{identify_key, KeyData};
use atproto_oauth::pkce;
use atproto_oauth::resources::{oauth_authorization_server, pds_resources};
use atproto_oauth::workflow::{
generate_state, oauth_complete, oauth_init, OAuthClient, OAuthRequest,
OAuthRequestState,
};
// Two distinct keys: this one signs client assertions; the DPoP key,
// passed separately, binds the tokens.
fn client() -> anyhow::Result<OAuthClient> {
Ok(OAuthClient {
redirect_uri: "https://your-app.com/callback".to_string(),
client_id: "https://your-app.com/client-metadata.json"
.to_string(),
private_signing_key_data: identify_key("did:key:zQ3sh...")?,
})
}
// 1 — Discovery, then the Pushed Authorization Request. Returns where to
// send the browser, and the PKCE verifier to store with the state.
async fn begin(
http_client: &reqwest::Client,
dpop_key: &KeyData,
) -> anyhow::Result<(String, String)> {
// The PDS named in alice's DID document says which server speaks for it.
let (_resource, auth_server) =
pds_resources(http_client, "https://pds.example.com").await?;
let (pkce_verifier, code_challenge) = pkce::generate();
let request_state = OAuthRequestState {
state: generate_state(),
code_challenge,
scope: "atproto transition:generic".to_string(),
};
let par = oauth_init(
http_client,
&client()?,
dpop_key,
Some("alice.bsky.social"),
&auth_server,
&request_state,
).await?;
// Store an OAuthRequest under request_state.state: the DID, the
// verifier, and auth_server.issuer. Step 2 cannot run without it.
Ok((par.request_uri, pkce_verifier))
}
// 2 — The callback: ?code=…&state=…&iss=…
async fn finish(
http_client: &reqwest::Client,
dpop_key: &KeyData,
stored: &OAuthRequest, // found by state, then deleted
code: &str,
iss: Option<&str>,
) -> anyhow::Result<()> {
// The server the flow began with, not one rediscovered now.
let auth_server =
oauth_authorization_server(http_client, &stored.issuer).await?;
let tokens = oauth_complete(
http_client,
&client()?,
dpop_key,
code,
&stored.subject, // expected_subject: a DID, never a handle
iss, // must equal stored.issuer (RFC 9207)
stored,
&auth_server,
).await?;
println!("{}", tokens.access_token);
Ok(())
}
The subject check is the interesting part
oauth_complete refuses a token whose sub claim does not equal the expected_subject you pass. That is not a formality — a mismatch means the authorisation server returned a token for a different account than the one the flow started for, and a client that skips the comparison will happily log somebody in as the wrong person.
Pass a DID, never a handle. OAuthRequest::subject documents the same requirement: the comparison is exact, and a handle will never equal a sub.
The callback’s iss is an argument for the same reason (RFC 9207). None is a refusal, not a skip, and an iss naming another server is the mix-up attack. A flow begun from a server hostname has no DID to pin; oauth_complete_with_binding with SubjectBinding::Discovered checks the sub it gets back instead.
Refreshing takes the account’s DID document rather than an endpoint, because it re-runs discovery itself and pins the subject to document.id:
use atproto_identity::key::KeyData;
use atproto_identity::model::Document;
use atproto_oauth::workflow::{oauth_refresh, OAuthClient, TokenResponse};
async fn rotate(
http_client: &reqwest::Client,
oauth_client: &OAuthClient,
dpop_key: &KeyData,
stored_refresh_token: &str,
did_document: &Document,
) -> anyhow::Result<TokenResponse> {
let tokens = oauth_refresh(
http_client,
oauth_client,
dpop_key,
stored_refresh_token,
did_document,
).await?;
Ok(tokens)
}
Expect a nonce challenge
An authorisation server may reject your first DPoP proof with use_dpop_nonce and supply the nonce it wants. That is the protocol working, not a failure. oauth_init, oauth_complete and oauth_refresh retry with it for you; hand-rolled clients that treat the first 400 as terminal will appear to work against some servers and not others.
The callback, JWKS and client-metadata handlers are yours to write in whatever framework you serve from; atproto-oauth-axum, which had them for Axum, has been removed. If your service mints or checks inter-service tokens instead, that is atproto-xrpcs: service_auth::verify_service_auth against a ServiceAuthPolicy naming the method and audience you expect.
§ 05
Write a record
Publishing is one XRPC call. Four things have to be right before you make it: which repository (a DID), which collection (an NSID), which record key, and which host to send it to.
That last one is the part people hard-code and regret. A PDS endpoint is not a constant — it is a property of the account, published in its DID document, and it moves when the account migrates. Resolve it every time.
# Prompts for the app password, or reads ATPROTO_PASSWORD.
atpxrpc login alice.bsky.social
echo '{
"repo": "alice.bsky.social",
"collection": "app.bsky.feed.post",
"rkey": "3kabcdefghij",
"record": {
"$type": "app.bsky.feed.post",
"text": "hello",
"createdAt": "2026-01-01T00:00:00.000Z"
}
}' | atpxrpc com.atproto.repo.putRecord --body -
login resolves the handle to a DID, takes the PDS endpoint from the DID document, and stores the session; each call after it goes to that PDS, refreshing the session if it has expired. --body - reads the request body from stdin, and without it the call is a query. That sequence is the same one you write in code.
use atproto_client::client::{Auth, DPoPAuth};
use atproto_client::com::atproto::repo::{
create_record, CreateRecordRequest, CreateRecordResponse,
};
use atproto_identity::key::identify_key;
use atproto_record::tid::Tid;
use serde_json::json;
async fn publish(
http_client: &reqwest::Client,
document: &atproto_identity::model::Document,
access_token: &str,
) -> anyhow::Result<()> {
// The endpoint comes from the account's own DID document.
let endpoints = document.pds_endpoints_validated();
let pds = endpoints.first().ok_or_else(|| anyhow::anyhow!("no PDS"))?;
// Tokens from the OAuth flow are DPoP-bound: the proof key travels
// with every request, not just the token exchange.
let auth = Auth::DPoP(DPoPAuth {
dpop_private_key_data: identify_key("did:key:zDna...")?,
oauth_access_token: access_token.to_string(),
});
let request = CreateRecordRequest {
repo: document.id.clone(),
collection: "app.bsky.feed.post".to_string(),
record_key: Some(Tid::new().to_string()),
validate: true,
record: json!({
"$type": "app.bsky.feed.post",
"text": "hello",
"createdAt": "2026-01-01T00:00:00.000Z"
}),
swap_commit: None,
};
match create_record(http_client, &auth, pds, request).await? {
CreateRecordResponse::StrongRef { uri, cid, .. } => {
println!("{uri} @ {cid}");
}
CreateRecordResponse::Error(err) => {
// Reached the server; the server said no.
anyhow::bail!("createRecord refused: {err:?}");
}
}
Ok(())
}
A refusal is not an Err
CreateRecordResponse is an untagged enum with two variants, and a server that rejects your write returns Ok(CreateRecordResponse::Error(..)) — not Err. The ? operator will not catch it.
Code that matches only StrongRef and waves the rest through with _ => unreachable!() compiles, passes a happy-path test, and silently treats every validation failure and every permission error as success. Match both variants. PutRecordResponse and DeleteRecordResponse have the same shape.
createRecord and putRecord differ in who owns the key. Omit record_key from a create and the server generates one, which is what you want for append-only collections like posts. putRecord requires a key and writes at it, so re-running it with the same key updates rather than duplicates — the right call for records with a natural identity, like a profile.
| Field | createRecord | putRecord |
|---|---|---|
| record_key | Option — server generates one when absent. | Required. |
| Re-running it | Writes another record. | Overwrites the one at that key. |
| swap_commit | Available. | Available. |
| swap_record | — | Available. |
validate: true asks the PDS to check the record against its lexicon before committing. Leave it on. A record that satisfies the schema is not automatically one the rest of the ecosystem will accept, but a record that fails it will not be accepted by anything.
§ 06
Consume the event stream
There are two streams and they are not interchangeable. Jetstream delivers the network’s repository events as plain JSON over one WebSocket — filtered server-side and already decoded, no CBOR, no CAR, no MST — and it is cheap. It is also trusted: events arrive without the cryptographic material needed to prove a PDS signed them. com.atproto.sync.subscribeRepos carries that material, and atproto-firehose reads it. The two crates share a CursorStore, so running both means implementing one store.
atproto-jetstream speaks v2 only, at /xrpc/network.bsky.jetstream.subscribeEvents, framed per proposal 0015. The v1 /subscribe endpoint is a different wire — different path, different framing, different parameter names — and remains served, wire-frozen, by the same hosts.
use async_trait::async_trait;
use atproto_jetstream::{
Ack, ConsumerConfig, CursorStore, EventSink, Filters,
Frame, JetstreamConsumer,
};
use tokio_util::sync::CancellationToken;
struct Printer;
#[async_trait]
impl EventSink for Printer {
async fn deliver(&self, frame: Frame) -> Result<Ack, String> {
if let Frame::Commit(commit) = frame {
println!("{} {} {}", commit.seq, commit.did, commit.collection);
}
Ok(Ack)
}
}
// `cursors` is yours: the crate will not decide where a cursor lives.
async fn run(cursors: impl CursorStore + 'static) -> anyhow::Result<()> {
let config = ConsumerConfig::new("jetstream.us-east.bsky.network")
.filters(Filters::commits_in(vec!["app.bsky.feed.post".to_string()]));
JetstreamConsumer::new(config, Printer, cursors)
.run(CancellationToken::new())
.await?;
Ok(())
}
Compression is a builder call on the consumer rather than a flag on the config: .compression(dictionary), with a ZstdDictionary fetched from the server itself. .max_frame_bytes(…) and .source(…) sit beside it, and so does .verify_cid(true) under the verify-cid feature.
use atproto_jetstream::{
fetch_dictionary, ConsumerConfig, CursorStore, EventSink, JetstreamConsumer,
};
async fn compressed<S: EventSink, C: CursorStore>(
config: ConsumerConfig,
sink: S,
cursors: C,
) -> anyhow::Result<JetstreamConsumer<S, C>> {
// `None` asks the server for the dictionary it is using now.
let dictionary =
fetch_dictionary("jetstream.us-east.bsky.network", None).await?;
Ok(JetstreamConsumer::new(config, sink, cursors).compression(dictionary))
}
Bluesky retrains the dictionary and retires the old id. The consumer does not fetch a new one itself: its next connection is refused with PreUpgradeError::UnknownZstdDictionary, and the answer is to fetch again and rebuild the consumer. The stored cursor carries the position across.
§ 07
Where next
- Concepts — what this workspace means by CID, DRISL, MST, TID, DPoP and NSID.
- Tools — every binary in the workspace, with the feature flags it needs.
- PDS — running
atproto-pds, and what it does not yet do. - Entryway —
atproto-entryway, the account service and authorization server in front of one or more PDS instances.