gimucco / atproto-php
AT Protocol OAuth 2.1 client for PHP — PKCE, DPoP, and PAR support for authenticating with Bluesky and other atproto services.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- ext-openssl: *
- ext-sodium: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^3.0
- web-token/jwt-library: ^3.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.50
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- phpstan/phpstan: ^1.11
- phpstan/phpstan-strict-rules: ^1.6
- phpunit/phpunit: ^10.5
Suggests
- guzzlehttp/guzzle: Recommended PSR-18 HTTP client (used in the examples)
README
AT Protocol OAuth 2.1 client for PHP — PKCE, DPoP, and PAR support for authenticating with Bluesky and other atproto services.
What this is (and isn't)
This library handles OAuth 2.1 authentication with any AT Protocol Personal Data Server (PDS). It gives you an authenticated session you can use to make API calls.
This is OAuth only. It does not implement Bluesky-specific operations like posting, fetching feeds, or managing profiles. For that, see the companion library (coming soon).
Why use this
Existing PHP libraries for AT Protocol only support the deprecated App Password flow. App Passwords are being phased out. The AT Protocol mandates a strict OAuth 2.1 profile that combines several features most OAuth libraries don't handle together:
- PKCE (S256 only) — Proof Key for Code Exchange
- DPoP — Demonstrating Proof of Possession, with mandatory server-issued nonces
- PAR — Pushed Authorization Requests
private_key_jwt— Client authentication via signed JWTs- Decentralized discovery — Handle → DID → PDS → Authorization Server
This library handles all of it.
Requirements
- PHP 8.1+
- Extensions:
json,curl,openssl,sodium - A PSR-18 HTTP client (Guzzle recommended)
- An HTTPS domain where you can host two JSON files (client metadata and JWKS)
Installation
composer require gimucco/atproto-php
# Recommended: Guzzle as the HTTP client
composer require guzzlehttp/guzzle
Quickstart
<?php use Gimucco\Atproto\ClientConfig; use Gimucco\Atproto\OAuthClient; use Gimucco\Atproto\Storage\FileSessionStore; use Gimucco\Atproto\Storage\FileStateStore; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; // 1. Configure $config = new ClientConfig( clientId: 'https://your-app.com/client-metadata.json', redirectUri: 'https://your-app.com/callback', scope: 'atproto transition:generic', clientName: 'My App', jwksUri: 'https://your-app.com/jwks.json', privateKey: file_get_contents('/path/to/private.pem'), ); // 2. Create the OAuth client $factory = new HttpFactory(); $oauth = new OAuthClient( config: $config, sessionStore: new FileSessionStore('/var/app/sessions', 'encryption-passphrase'), stateStore: new FileStateStore('/var/app/states', 'encryption-passphrase'), httpClient: new Client(), requestFactory: $factory, streamFactory: $factory, ); // 3. Start login — redirect the user $authUrl = $oauth->beginAuthorization('alice.bsky.social'); header('Location: '.$authUrl); // 4. Handle callback (in your callback endpoint) $session = $oauth->completeAuthorization($_GET['code'], $_GET['state'], $_GET['iss']); // 5. Make authenticated requests $response = $session->authenticatedRequest( 'GET', $session->pdsUrl.'/xrpc/com.atproto.server.getSession', ); echo $response->getBody(); // {"did":"did:plc:...","handle":"alice.bsky.social",...}
Concepts
Client Metadata Document
AT Protocol OAuth uses a URL as the client_id. The authorization server fetches this URL to learn about your application. You host a JSON document containing your app name, redirect URIs, and public key. Use ClientMetadataBuilder::fromConfig() to generate it.
JWKS (JSON Web Key Set)
Confidential clients authenticate using private_key_jwt — signing a JWT with an ES256 private key. The corresponding public key is published as a JWKS document. Use ClientMetadataBuilder::jwksFromConfig() to generate it.
DPoP (Demonstrating Proof of Possession)
Every token and API request includes a DPoP proof — a short-lived JWT proving the request came from the holder of a specific key. This library handles DPoP automatically, including the mandatory server nonce exchange (the "double-call" pattern).
Session Storage
Sessions hold access tokens, refresh tokens, and DPoP keys. This library provides three storage backends. See Session Storage below.
For deeper reading, see the AT Protocol OAuth specification and the OAuth client implementation guide.
Generating your client keypair
Generate an ES256 (P-256) private key:
openssl ecparam -genkey -name prime256v1 -noout -out private.pem
This key is used for two things:
- Client assertion — authenticating to the token endpoint
- JWKS — publishing the public key for the authorization server to verify
Keep private.pem secret. Never commit it to version control.
Hosting client metadata and JWKS
You need to serve two JSON documents at stable HTTPS URLs: client-metadata.json and jwks.json. Their contents are static for any given app, derived from your config and key.
Generate them with the bundled CLI tool:
bin/generate-metadata --config=path/to/config.php --output=path/to/public
This writes client-metadata.json and jwks.json to the output directory. Re-run after any config or key change. The output files are static — serve them directly via Nginx, Apache, or any CDN. No PHP needed at request time.
For example
bin/generate-metadata --config=examples/config.php --output=examples/public
Important: The client_id value in your config must exactly match the URL where client-metadata.json is hosted.
If you'd rather generate them in your own application code (e.g., during a deploy hook), the underlying API is ClientMetadataBuilder::fromConfig($config) and ClientMetadataBuilder::jwksFromConfig($config) — both return associative arrays you can json_encode and write wherever you like.
The full OAuth flow
Step 1: Start authorization
The library supports two flows:
Identity-first — the user enters their handle, you pre-fill the auth server's identifier field:
$authUrl = $oauth->beginAuthorization('alice.bsky.social'); // Redirect the user's browser header('Location: '.$authUrl); exit;
The library resolves the handle → DID → PDS → auth server, then sends the PAR with login_hint set so the auth server pre-fills the identifier on its sign-in page.
Server-first — the user picks their account on the auth server's own page:
$authUrl = $oauth->beginAuthorization(); // no handle header('Location: '.$authUrl); exit;
This skips identity resolution entirely and redirects to the auth server configured by ClientConfig::$defaultAuthorizationServer (default: https://bsky.social). The user's actual identity is determined post-auth from the sub claim in the token response, then resolved.
To target a specific atproto host (e.g., a self-hosted PDS) without entering a handle, override per-call or in config:
// Per-call override $authUrl = $oauth->beginAuthorization( handleOrDid: null, authorizationServer: 'https://auth.example.com', ); // Or set the project-wide default once in ClientConfig: $config = new ClientConfig( // ... defaultAuthorizationServer: 'https://auth.example.com', );
Step 2: Handle the callback
// The authorization server redirects back with code, state, and iss $session = $oauth->completeAuthorization( code: $_GET['code'], state: $_GET['state'], iss: $_GET['iss'], ); // $session->did — "did:plc:..." // $session->handle — "alice.bsky.social" // $session->pdsUrl — "https://bsky.social" (or wherever their PDS is)
The library validates:
- The
statematches a pending authorization - The
issmatches the expected authorization server - The
subin the token response matches the resolved DID
Making authenticated requests
// GET request $response = $session->authenticatedRequest( 'GET', $session->pdsUrl.'/xrpc/com.atproto.server.getSession', ); // POST request with JSON body $response = $session->authenticatedRequest( 'POST', $session->pdsUrl.'/xrpc/com.atproto.repo.createRecord', [ 'repo' => $session->did, 'collection' => 'app.bsky.feed.post', 'record' => [ 'text' => 'Hello from atproto-php!', 'createdAt' => date('c'), ], ], );
Every request automatically:
- Attaches the
Authorization: DPoP <token>header - Generates a fresh DPoP proof JWT with
htm,htu,ath, andnonceclaims - Handles the
use_dpop_nonceretry if the server requires a nonce - Refreshes the access token if it's near expiry
Session storage
InMemory (testing)
use Gimucco\Atproto\Storage\InMemorySessionStore; use Gimucco\Atproto\Storage\InMemoryStateStore; $sessionStore = new InMemorySessionStore(); $stateStore = new InMemoryStateStore();
File-based
use Gimucco\Atproto\Storage\FileSessionStore; use Gimucco\Atproto\Storage\FileStateStore; $sessionStore = new FileSessionStore( directory: '/var/app/sessions', passphrase: 'your-strong-passphrase', // encrypts tokens at rest ); $stateStore = new FileStateStore( directory: '/var/app/states', passphrase: 'your-strong-passphrase', );
PDO (MySQL, PostgreSQL, SQLite)
use Gimucco\Atproto\Storage\PdoSessionStore; use Gimucco\Atproto\Storage\PdoStateStore; use Gimucco\Atproto\Storage\Pdo\Schema; $pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass'); // Create tables (run once) $sql = Schema::createTablesSql('mysql'); $pdo->exec($sql['sessions']); $pdo->exec($sql['states']); $sessionStore = new PdoSessionStore($pdo, passphrase: 'your-strong-passphrase'); $stateStore = new PdoStateStore($pdo, passphrase: 'your-strong-passphrase');
Schema supports MySQL, PostgreSQL, and SQLite:
$sql = Schema::createTablesSql('mysql'); // or 'pgsql' or 'sqlite'
Encryption at rest
When you provide a passphrase, access tokens, refresh tokens, and DPoP private keys are encrypted using sodium_crypto_secretbox before storage. If you don't provide one, a warning is logged but storage works in plaintext.
Token refresh
Automatic
When you call $session->authenticatedRequest(), the library checks if the access token is near expiry (within 60 seconds) and refreshes it automatically.
Manual
$session->refresh();
Checking expiry
if ($session->isExpired()) { // Token has expired } $expiresAt = $session->expiresAt(); // DateTimeImmutable
Error handling
All exceptions extend Gimucco\Atproto\Exception\AtprotoException:
use Gimucco\Atproto\Exception\ResolutionException; use Gimucco\Atproto\Exception\AuthorizationException; use Gimucco\Atproto\Exception\TokenException; use Gimucco\Atproto\Exception\DpopException; use Gimucco\Atproto\Exception\SessionException; use Gimucco\Atproto\Exception\ConfigurationException; use Gimucco\Atproto\Exception\NetworkException; try { $authUrl = $oauth->beginAuthorization($handle); } catch (ResolutionException $e) { // Handle/DID/PDS could not be resolved } catch (AuthorizationException $e) { // PAR request failed } catch (NetworkException $e) { // HTTP transport failure } try { $session = $oauth->completeAuthorization($code, $state, $iss); } catch (TokenException $e) { // Token endpoint returned an error echo $e->error; // e.g., "invalid_grant" echo $e->errorDescription; // Human-readable message } catch (AuthorizationException $e) { // State/issuer/sub mismatch }
Exception hierarchy
AtprotoException (base)
├── ResolutionException — handle/DID/PDS resolution failures
├── AuthorizationException — OAuth flow errors
│ └── TokenException — token endpoint errors (has error, errorDescription, errorUri)
├── DpopException — DPoP proof generation failures
├── SessionException — storage/refresh/restore failures
├── ConfigurationException — invalid client config
└── NetworkException — HTTP transport failures
Logging
Inject any PSR-3 logger:
use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('atproto'); $logger->pushHandler(new StreamHandler('/var/log/atproto.log')); $oauth = new OAuthClient( config: $config, sessionStore: $sessionStore, stateStore: $stateStore, httpClient: $httpClient, requestFactory: $factory, streamFactory: $factory, logger: $logger, );
The library logs:
- Debug: DPoP nonce retries
- Warning: Unencrypted storage backends
- Error: Failed token refreshes
Security
The library applies several protections by default: SSRF blocking on outbound requests, sub-claim and issuer validation on the OAuth callback, and optional libsodium encryption of tokens and DPoP keys at rest.
See SECURITY.md for the full security model, threat model, vulnerability reporting, and guidance on storing the client private key, choosing an encryption passphrase, and the SSRF guard's TOCTOU window.
Troubleshooting
"use_dpop_nonce" errors
This is normal. The AT Protocol requires DPoP nonces, and the first request to any server will fail with use_dpop_nonce. The library retries automatically. If you see this in logs, it's working correctly.
client_id URL must match exactly
The URL in your ClientConfig::clientId must match the URL where you host the client metadata document exactly — same scheme, host, path, and no trailing slash differences.
Clock skew on DPoP iat
DPoP proofs include an iat (issued-at) timestamp. If your server clock is off by more than a few seconds, the authorization server may reject them. Use NTP to keep your clock synced.
"Sub mismatch" error
After token exchange, the library verifies the sub claim in the token response matches the DID resolved during beginAuthorization(). If they don't match, this is a security check failure — the authorization server returned tokens for a different user than expected.
SSL certificate issues in development
If you're testing locally, you may need to configure your HTTP client to accept self-signed certificates. With Guzzle:
$httpClient = new \GuzzleHttp\Client(['verify' => false]);
Never do this in production.
Testing your integration
See the examples/ directory for a complete working example you can run locally:
cd examples cp config.example.php config.php # Edit config.php with your settings php -S localhost:8080 -t public
Roadmap
Planned for future releases (not yet implemented):
- Token revocation (RFC 7009) — explicit
revokeSession()calls currently delete local state but don't notify the authorization server. A revocation endpoint call will be added when the AT Protocol spec finalizes the contract. - Bidirectional handle verification — after resolving handle → DID, fetch the DID document and confirm the
alsoKnownAsfield lists the original handle. Today the library trusts the handle-to-DID mapping; bidirectional verification protects against handle squatting if a DNS or.well-knownrecord is compromised. - Rate limiting / exponential backoff on nonce retry — the
use_dpop_nonceretry currently fires once. Pathological servers that loop through nonces without converging would cause repeated requests; a configurable retry budget will be added. did:plcdirectory fallback URLs — currently useshttps://plc.directoryonly. Future versions will accept a list of mirror directories and try them in order.- Optional DNS pinning in the SSRF guard for transports that support it (e.g., Guzzle with
CURLOPT_RESOLVE).
Contributing
See CONTRIBUTING.md for development setup, testing, and submission guidelines.
License
This project is licensed under GPL-2.0-or-later. See LICENSE for the full text.
Attribution
Portions of this library are adapted from Automattic/wordpress-atmosphere, licensed under GPL-2.0-or-later. Original copyright Automattic Inc.
Acknowledgments
- Automattic and the ATmosphere team for the reference PHP implementation
- The Bluesky team for the AT Protocol and its OAuth profile
- RFC authors: RFC 9449 (DPoP), RFC 9126 (PAR), RFC 7636 (PKCE)