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 - ✅
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 (null when resolved from a DID)
print(identity.signingKey); // #atproto publicKeyMultibase (null when absent)
}
When resolution starts from a handle, the resolver performs bidirectional handle verification: the DID document must list at://<handle> in its alsoKnownAs, otherwise an IdentityException is thrown.
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.
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