atproto_identity

atproto_identity resolves AT Protocol identities — a handle or DID to its PDS, DID document, and #atproto signing key — and verifies inbound AppView service-auth JWTs. It is the identity layer used by atproto_oauth and is well suited to feed generators and other services that must authenticate requests from the AppView.
Use atproto_identity for:
- Resolving handles/DIDs to a PDS and signing key
- Verifying
com.atproto-style service-auth JWTs on a server (feed generators, labelers) - A hardened, SSRF-aware
did:webresolver
- Full AT Protocol / Bluesky client functionality (these include identity resolution)
Features ⭐
- ✅ Identity Resolution - Handle/DID → PDS origin, DID document, and
#atprotosigning key - ✅ Bidirectional Handle Verification - Confirms the DID document claims the handle back via
alsoKnownAs - ✅ Service-Auth JWT Verification - Validates AppView service-auth tokens (ES256K/ES256)
- ✅ SSRF / DoS Hardened -
did:webhost allowlisting, private-network blocking, response-size caps, and timeouts - ✅ Raw DID Documents - Reads documents the atproto identity model does not describe, such as a feed generator's
- ✅
did:plcanddid:webSupport - Resolves both DID methods - ✅ Pluggable -
IdentityResolveris an interface you can implement - ✅ Web/WASM Friendly - Pure Dart, no
dart:iorequirement
Getting Started 💪
Install
See the Install Package section for more details on how to install a package in your Dart and Flutter app.
With Dart:
dart pub add atproto_identity
With Flutter:
flutter pub add atproto_identity
Import
import 'package:atproto_identity/atproto_identity.dart';
Resolve an Identity
Use the default HttpIdentityResolver to resolve a handle or DID. It returns a ResolvedIdentity.
import 'package:atproto_identity/atproto_identity.dart';
Future<void> main() async {
final resolver = HttpIdentityResolver();
// Accepts a handle (optionally `@`/`at://` prefixed) or a DID.
final identity = await resolver.resolve('shinyakato.dev');
print(identity.did); // did:plc:...
print(identity.pds); // https://host (PDS origin, no trailing slash)
print(identity.handle); // shinyakato.dev, or 'handle.invalid'
print(identity.signingKey); // #atproto publicKeyMultibase (null when absent)
}
The resolver performs bidirectional handle verification in both directions of entry.
Starting from a handle, the DID document must list at://<handle> in its alsoKnownAs, otherwise an IdentityException is thrown.
Starting from a DID, the handle the document claims is resolved back and must return that same DID. Anything else — no claim, a claim that resolves elsewhere, or a handle resolver that cannot be reached — reports handle.invalid rather than throwing, because an account whose handle stopped resolving (a removed or misconfigured DNS record, most often) is still a valid account. handle is therefore never null; it is either a verified handle or handle.invalid, matching com.atproto.identity.defs#identityInfo.
More Tips 🏄
Hardening did:web Resolution (SSRF / DoS)
A did:web issuer is attacker-controlled input that drives outbound HTTP, so HttpIdentityResolver ships with conservative defaults and exposes knobs to tighten them:
import 'package:atproto_identity/atproto_identity.dart';
final resolver = HttpIdentityResolver(
// Only these did:web hosts may be contacted (lowercase, no port). When null,
// any host is allowed subject to the private-network check below.
allowedHosts: {'example.com'},
// Reject localhost and private/loopback/link-local/CGNAT/unique-local/
// multicast/reserved IP *literals* before any request is issued. Defaults
// to false (i.e. such hosts are blocked).
allowPrivateNetwork: false,
// Per-request timeout applied to connection and body read (default 10s).
timeout: const Duration(seconds: 10),
// Reject responses larger than this before JSON decoding (default 512 KiB).
maxResponseBytes: 512 * 1024,
);
allowPrivateNetwork only inspects IP literals — no DNS resolution is performed (this package targets web/WASM too). A public hostname whose DNS record points at a private address is not detected here, so pair it with allowedHosts and operator-level egress controls for defense in depth.
You can also point the resolver at custom infrastructure:
final resolver = HttpIdentityResolver(
handleResolver: 'https://public.api.bsky.app', // handle → DID service
plcDirectory: 'https://plc.directory', // did:plc directory
);
Verifying Service-Auth JWTs
On a server (for example a feed generator), verify each inbound AppView service-auth JWT with verifyServiceAuth. It validates the JOSE header (only ES256K/ES256 are accepted), audience, time claims, and signature — resolving the issuer's signing key via an IdentityResolver — and returns the verified issuer (viewer) DID.
import 'package:atproto_identity/atproto_identity.dart';
Future<void> handleRequest(String authorizationHeader) async {
final resolver = HttpIdentityResolver();
try {
final viewerDid = await verifyServiceAuth(
authorizationHeader, // "Bearer <jwt>"
serviceDid: 'did:web:feed.example.com', // must equal the token's `aud`
resolver: resolver,
expectedLxm: 'app.bsky.feed.getFeedSkeleton', // optional `lxm` check
// maxTokenLifetime: Duration(minutes: 60), // bound `exp`; null to skip
);
print('Authenticated viewer: $viewerDid');
} on IdentityException catch (e) {
// Malformed header/JWT, wrong audience, expired token, bad signature, ...
print('Rejected: ${e.message}');
}
}
Every failure — a malformed Bearer header or JWT, an untrusted alg (none/HS*/RSA are rejected), a wrong audience, an expired or not-yet-valid token, an exp beyond maxTokenLifetime, an lxm mismatch, an unresolvable issuer, a missing signing key, or a signature that does not verify — throws an IdentityException.
Reading a Raw DID Document
resolve(...) reads a DID document as an atproto identity, so it requires an #atproto_pds service and rejects any document without one. Not every DID document describes an account: a feed generator publishes a did:web document whose only service is #bsky_fg, and resolve(...) throws on it.
resolveDidDocument returns such a document verbatim, as decoded JSON, through the same hardened fetch — the timeout, size cap and redirect cap on every fetch, plus, for did:web, the host policy and the binding of the document's id to the DID you asked for. Only the atproto-specific interpretation is skipped.
The values inside a DID document are not validated: every serviceEndpoint in it — the #atproto_pds entry included, since that one is checked only on the resolve(...) path — is attacker-controlled text that has passed no scheme or host policy. serviceEndpointOf reads one out for you and holds it to the same bar the resolver applies to a PDS endpoint — https only, no credentials, query, or fragment, and no localhost or reserved IP literal.
import 'package:atproto_identity/atproto_identity.dart';
Future<void> main() async {
final resolver = HttpIdentityResolver();
const did = 'did:web:foryou.club';
final document = await resolver.resolveDidDocument(did);
final endpoint = serviceEndpointOf(
document,
did,
// Matches both `#bsky_fg` and `<did>#bsky_fg`.
id: '#bsky_fg',
// Optional; when given, the entry's type must equal it.
type: 'BskyFeedGenerator',
);
print(endpoint); // https origin (path preserved), or null
}
resolveDidDocument is declared on HttpIdentityResolver rather than on the IdentityResolver interface, so implementing your own resolver (see Custom Resolvers) stays a one-method job. Hold onto the concrete resolver where you need document access.
Extracting a Signing Key
If you already have a DID document, signingKeyOf returns the publicKeyMultibase of its #atproto verification method, or null when the document declares none. The id must match #atproto or <did>#atproto exactly — a loose suffix match is deliberately avoided so a crafted document cannot smuggle in an attacker-controlled key.
import 'package:atproto_identity/atproto_identity.dart';
void main() {
final Map<String, dynamic> didDocument = /* ... */ {};
final multibase = signingKeyOf(didDocument, 'did:plc:iijrtk7ocored6zuziwmqq3c');
print(multibase);
}
Custom Resolvers
IdentityResolver is a plain interface, so you can supply your own (e.g. a caching layer) anywhere a resolver is accepted, including verifyServiceAuth and atproto_oauth's OAuthClient:
import 'package:atproto_identity/atproto_identity.dart';
final class CachingIdentityResolver implements IdentityResolver {
CachingIdentityResolver(this._delegate);
final IdentityResolver _delegate;
final _cache = <String, ResolvedIdentity>{};
Future<ResolvedIdentity> resolve(String identity) async =>
_cache[identity] ??= await _delegate.resolve(identity);
}
Related Packages
- atproto_oauth - Pluggable OAuth 2.0 client that uses
IdentityResolverfor account discovery - did_plc - DID PLC Directory client (used internally for signature verification)
- atproto / bluesky - Full AT Protocol / Bluesky clients