AT Protocol OAuth 2.1 client for PHP — PKCE, DPoP, and PAR support for authenticating with Bluesky and other atproto services.
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 gimucco/bluesky-php.
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.
- PHP 8.2+
- 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)
composer require gimucco/atproto-php
# Recommended: Guzzle as the HTTP client
composer require guzzlehttp/guzzle<?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",...}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.
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.
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).
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.
Generate an ES256 (P-256) private key:
openssl ecparam -genkey -name prime256v1 -noout -out private.pemThis 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.
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/publicThis 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/publicImportant: 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.
A single client_id can declare multiple redirect_uris — useful when one app needs distinct callback URLs for separate flows (e.g., login vs. account-linking, or web vs. mobile). The CLI accepts an array:
// config.php for bin/generate-metadata
return [
'client_id' => 'https://your-app.com/client-metadata.json',
'redirect_uri' => [
'https://your-app.com/callback/login',
'https://your-app.com/callback/link',
],
// …
];At runtime, create one ClientConfig per flow — same clientId, privateKey, and encryptionPassphrase, different redirectUri. Sessions are keyed by DID and refreshable through any config that shares those three fields, so a session minted by the login flow is fully usable from the link flow:
$loginConfig = new ClientConfig(
clientId: 'https://your-app.com/client-metadata.json',
redirectUri: 'https://your-app.com/callback/login',
privateKey: $pem,
encryptionPassphrase: $passphrase,
// …
);
$linkConfig = new ClientConfig(
clientId: 'https://your-app.com/client-metadata.json',
redirectUri: 'https://your-app.com/callback/link',
privateKey: $pem, // same
encryptionPassphrase: $passphrase, // same
// …
);If you generate the metadata document programmatically (not via the CLI), pass the extra URIs through additionalRedirectUris:
$metaConfig = new ClientConfig(
clientId: 'https://your-app.com/client-metadata.json',
redirectUri: 'https://your-app.com/callback/login',
additionalRedirectUris: ['https://your-app.com/callback/link'],
// …
);
file_put_contents('public/client-metadata.json', json_encode(
ClientMetadataBuilder::fromConfig($metaConfig)
));additionalRedirectUris is purely declarative — it only affects the metadata JSON. At runtime, each ClientConfig still uses its own redirectUri for PAR and token exchange.
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',
);// 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
// 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
For endpoints that accept binary input — e.g. com.atproto.repo.uploadBlob for images and videos — use authenticatedRawRequest(). The body is sent verbatim with a caller-controlled Content-Type:
// Upload an image as a blob (returns a blob ref you can embed in a post)
$bytes = file_get_contents('/path/to/photo.jpg');
$response = $session->authenticatedRawRequest(
method: 'POST',
url: $session->pdsUrl.'/xrpc/com.atproto.repo.uploadBlob',
body: $bytes,
contentType: 'image/jpeg',
);
$blob = json_decode((string) $response->getBody(), true)['blob'];
// $blob is now { "$type": "blob", "ref": {...}, "mimeType": "image/jpeg", "size": ... }
// — embed it in an app.bsky.feed.post record's `embed.images[].image` fieldSame guarantees as authenticatedRequest(): DPoP proof, nonce retry, auto-refresh. The body is passed through unchanged — no JSON encoding, no transformation. If you need to override Content-Type after the fact (rare), an entry in $headers wins (case-insensitive).
authenticatedRequest() (array body, JSON-encoded) and authenticatedRawRequest() (string body, caller-typed) are the two primitives. Use the former for typed XRPC procedures, the latter for binary uploads.
use Gimucco\Atproto\Storage\InMemorySessionStore;
use Gimucco\Atproto\Storage\InMemoryStateStore;
$sessionStore = new InMemorySessionStore();
$stateStore = new InMemoryStateStore();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',
);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'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.
When you call $session->authenticatedRequest(), the library checks if the access token is near expiry (within 60 seconds) and refreshes it automatically.
$session->refresh();if ($session->isExpired()) {
// Token has expired
}
$expiresAt = $session->expiresAt(); // DateTimeImmutableAll 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
}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
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
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.
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.
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.
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.
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.
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.
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 publicPlanned 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).
See CONTRIBUTING.md for development setup, testing, and submission guidelines.
This project is licensed under GPL-2.0-or-later. See LICENSE for the full text.
Portions of this library are adapted from Automattic/wordpress-atmosphere, licensed under GPL-2.0-or-later. Original copyright Automattic Inc.
- 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)