Ever wanted to add ATProtocol authentication to your website and check if visitors support their favorite creators? In this tutorial, we'll walk through building a minimal single-page application that authenticates users via ATProtocol OAuth and validates their supporter status using the ATProtoFans API.
What We're Building
Our demo app does three things:
Authenticates users with their ATProtocol identity (e.g.,
yourname.bsky.social)Retrieves their DID after successful OAuth
Checks if they're a supporter of a specific creator via the
com.atprotofans.validateSupporterXRPC endpoint
The entire application runs in the browser with no backend server required—just static file hosting.
Understanding ATProtocol OAuth
ATProtocol OAuth is a bit different from typical OAuth implementations you might be familiar with. Here's what makes it unique:
Dynamic Client Registration
Instead of pre-registering your app with a central authority, ATProtocol uses client metadata URLs. Your client_id is literally a URL pointing to a JSON file that describes your application:
{
"client_id": "https://yoursite.com/oauth-client-metadata.json",
"client_name": "Your App Name",
"client_uri": "https://yoursite.com/",
"redirect_uris": ["https://yoursite.com/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
This decentralized approach means anyone can create an OAuth client without asking permission.
Handle Resolution
Users identify themselves with handles like alice.bsky.social, but the protocol needs DIDs (Decentralized Identifiers) to work. Our app resolves handles in two ways:
DNS TXT records: Query
_atproto.alice.bsky.socialfor a TXT record containingdid=did:plc:...HTTPS well-known: Fetch
https://alice.bsky.social/.well-known/atproto-did
async function resolveHandle(handle) {
// Try DNS first via Google's DNS-over-HTTPS
const res = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`);
const data = await res.json();
// Parse TXT record for did=... value
// Fall back to HTTPS if DNS fails
}
Authorization Server Discovery
Once we have a DID, we need to find where to send the user for authentication. This involves:
Resolve the DID document from
plc.directory(fordid:plc:) or the domain's well-known endpoint (fordid:web:)Find the PDS service endpoint in the DID document
Fetch OAuth metadata from the PDS's
/.well-known/oauth-authorization-server
The metadata tells us the authorization and token endpoints we need.
PKCE (Proof Key for Code Exchange)
PKCE is mandatory for browser-based apps. We generate a random code verifier (43-128 characters) and create a SHA-256 hash as the challenge:
const verifier = randomString(96); // ~128 chars when base64url encoded
const challenge = base64UrlEncode(await sha256(verifier));
The verifier stays in sessionStorage until we exchange the authorization code for tokens.
DPoP (Demonstration of Proof of Possession)
Here's where ATProtocol OAuth gets interesting. Every token request and authenticated API call requires a DPoP proof—a signed JWT that binds the request to a specific cryptographic key pair.
async function createDPoPProof(privateKey, publicKey, method, url, ath, nonce) {
const header = {
typ: 'dpop+jwt',
alg: 'ES256',
jwk: { kty: publicKey.kty, crv: publicKey.crv, x: publicKey.x, y: publicKey.y }
};
const payload = {
jti: randomString(32), // Unique token ID
htm: method, // HTTP method (GET, POST)
htu: url, // Target URL
iat: Math.floor(Date.now() / 1000)
};
if (ath) payload.ath = ath; // Access token hash (for API calls)
if (nonce) payload.nonce = nonce; // Server-provided nonce
// Sign with ES256 (ECDSA P-256)
return signJWT(header, payload, privateKey);
}
This prevents token theft—even if someone intercepts your access token, they can't use it without your private key.
The Nonce Dance
ATProtocol authorization servers require a server-provided nonce in DPoP proofs. The flow looks like this:
Make token request without nonce
Server responds with
400 use_dpop_nonceand adpop-nonceheaderCreate new DPoP proof including the nonce
Retry the token request
let tokenRes = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'DPoP': dpopProof },
body: tokenBody
});
if (!tokenRes.ok) {
const error = await tokenRes.json();
if (error.error === 'use_dpop_nonce') {
const nonce = tokenRes.headers.get('dpop-nonce');
dpopProof = await createDPoPProof(privateKey, publicKey, 'POST', tokenEndpoint, null, nonce);
tokenRes = await fetch(tokenEndpoint, { /* retry with new proof */ });
}
}
Validating Supporter Status
Once authenticated, checking supporter status is straightforward. The com.atprotofans.validateSupporter endpoint is a public XRPC query that doesn't require authentication:
async function checkSupporter() {
const session = getSession();
const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter');
url.searchParams.set('supporter', session.did); // The logged-in user
url.searchParams.set('subject', 'did:plc:abc123...'); // The creator
url.searchParams.set('signer', 'did:plc:xyz789...'); // The attestation broker
const res = await fetch(url);
const data = await res.json();
if (data.valid) {
// User is a verified supporter!
}
}
The endpoint returns:
{
"valid": true,
"profile": {
"did": "did:plc:...",
"handle": "supporter.bsky.social",
"displayName": "Supporter Name"
}
}
Putting It All Together
Here's the complete authentication flow:
User enters handle
↓
Resolve handle → DID
↓
Resolve DID → DID Document
↓
Find PDS → OAuth metadata
↓
Generate PKCE + DPoP keys
↓
Redirect to authorization endpoint
↓
User authorizes → redirect back with code
↓
Exchange code for tokens (with DPoP nonce retry)
↓
Store session (tokens + DPoP keys)
↓
Check supporter status via ATProtoFans API
Security Considerations
A few things to keep in mind:
Session storage: We use
sessionStoragefor tokens and keys, which clears when the tab closes. For persistent sessions, considerlocalStoragewith proper encryption.DPoP keys: These are generated fresh each session. The private key never leaves the browser.
PKCE: Protects against authorization code interception attacks.
State parameter: Prevents CSRF attacks during the OAuth flow.
Try It Yourself
View the live demo to see it in action, or check out the source code on Tangled to explore the implementation.
The application runs entirely in the browser. To adapt it for your own site, you'll need:
index.html- The single-page applicationoauth-client-metadata.json- Your OAuth client configuration
For production, make sure your client_id URL matches where you're hosting the metadata file, and update the redirect_uris accordingly.
What's Next?
This demo shows the basics, but there's more you can build:
Token refresh: Access tokens expire; use the refresh token to get new ones
Authenticated XRPC calls: Make calls to the user's PDS for profile data, posts, etc.
Multiple creator support: Check supporter status across different creators
Gated content: Show exclusive content only to verified supporters
ATProtocol OAuth opens up possibilities for decentralized identity across the web. Combined with ATProtoFans supporter validation, you can build creator-supporter experiences that respect user privacy and work across the entire ATProtocol ecosystem.
Happy building!
Look vibe coded? It is! This is the prompt we used to one-shot build this application with Claude Code:
Create a ultra-minimal browser web application that showcases the functionality of ATProtocol OAuth and https://atprotofans.com/ support. This single-page application allows visitors to authenticate using ATProtocol OAuth and once their logged in, makes an XRPC call to the https://atprotofans.com/xrpc/com.atprotofans.validateSupporter XRPC endpoint as described https://atprotofans.com/help/xrpc to see if they are a supporter of ngerakines.me (DID did:plc:cbkjy5n7bk3ax2wplmtjofq2) as attested by the broker signer did:plc:zwimedywn2ktwss7m5z37qsk.
The initial output was great and required one additional fix:
It looks like the PDS should be treated as a protected resource and then the authorization server extracted and the meta-data fetched from there.
If you use this, we highly recommend a thorough review of the code. We spent a small amount of time verifying the functionality only, and that did not include a thorough review of the cryptography, surface area, and risk of the application. The use of a single "atproto" scope meant that harm to data within the PDS is minimal, but security securing your and your user's data requires a constant, evolving view from many angles.