Building a Browser-Based ATProtocol OAuth App with Supporter Validation

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.validateSupporter XRPC 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.social for a TXT record containing did=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 (for did:plc:) or the domain's well-known endpoint (for did: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_nonce and a dpop-nonce header

Create 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 sessionStorage for tokens and keys, which clears when the tab closes. For persistent sessions, consider localStorage with 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 application

oauth-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.