Building OAuth Authentication for ATProto apps: Part 1, the web use-case

I have been experimenting quite a bit with Bluesky and ATProto, collectively known as the Atmosphere, lately and one of the issues that keeps returning for every new tool or app I build is authenticating users. If you are building an app that needs to post things to Bluesky or store things on the users PDS you will need your users to authenticate with their Atmosphere account.

This article is a follow-up to my previous article on implementing OAuth for Bluesky/ATProto apps . The previous article was a bit light on details, so in this one I will try to do more show and tell. Since the previous article I have also simplified my authentication flows a lot, so hopefully it's also a bit simpler to explain this time round.

How can user authenticate on ATProto

There are two ways to do this right now;

Either your users login to your app with their main email/password, or an app password they created for your app.

Or they authenticate with OAuth.

While you will need the app password method for purely backend tools that need to do things on behalf of your users, for anything the user does themselves in a mobile or web client OAuth is the better choice. With this method your app does not need to store passwords and you can limit the sort of things a user gives your app permissions for (although at the time of writing granular permissions are still a work in progress ).

🔑 New to ATProto? If you're reading this but aren't really clear on what ATProto actually is, how a PDS or Lexicon is involved in this, or any of the other ATProto terms and concepts please read Kuba Suder's excellent introduction on ATProto first.

A disclaimer up front

As with any technical article the specific details only make sense to someone wanting to do similar things with similar technology. So before we start let's see what I'm actually trying to do.

For this article I will describe how I setup the backend and mobile client for Anchor , my experiment in making a social checkin app that stores checkins in your own PDS.

To make Anchor work I needed:

A backend that can post to ATProto on behalf of the user

A web client that knows who the user is so it can show user specific feeds

A mobile client that can show same feeds, but can also create checkins based on the users location

So my users will authenticate on the web with OAuth, or in a webview from the mobile app, and then use the backend service to actually get feeds or post things.

My backend is using Valtown as a hosting service which means I'm using Typescript on the Deno runtime and not plain Node.js (we'll later find out why that matters)

The web frontend is React on that same service

The mobile client is a simple SwiftUI native app (iOS only for now, sorry Android folks)

If you want to use another tech stack you may still learn something here but you may want to do things differently. In fact if Node.js is a good option for you I'd recommend you do that instead of Deno. Due to some limitations of the Deno crypto library there is currently no built-in way to do DPoP for ATProto which means we have to wrangle some crypto code manually. This is likely just a temporary issue though.

The complete flow involves 13 steps across 4 components: your app backend, the user's browser, a handle resolver (Slingshot), and the user's PDS. Each plays a specific role in the decentralized authentication model.

What we will be doing today

OAuth is an already a bit complex to grasp, and there are many different ways to implement it, but OAuth for ATProto specifically has some pointy bits you will need to watch for. So we're just going to take this step by step.

Let's start with a user logging into the web app since that's the 'simplest' flow. Throughout these examples we will be referring to specific files in the open source backend code of Anchor. You can find this code on GitHub.

The Anchor architecture also has mobile auth endpoints for the mobile app to use but I'll leave those for a Part 2, coming soon..

Before we dive into the code, let's visualize the complete OAuth flow. This diagram shows all steps from when a user enters their handle to when they're fully authenticated with an encrypted session cookie.

The user wishes to login

1. User enters their handle

For this step we'll need a form where the user can fill in their handle e.g. tijs.org or shenanigansen.bsky.social‬ With this handle we can go right to the next step.

2. Validate & resolve handle

Once we know who wants to login we need to check if the handle is valid and whether it exists on the ATProto network.

import { resolveHandleWithSlingshot } from "./slingshot-resolver.ts";
import { isValidHandle } from "npm:@atproto/syntax@0.4.0";

async function validateHandle(handle: string) {
// Step 1: AT Protocol syntax validation
if (!isValidHandle(handle)) {
throw new Error("Invalid handle format");
}

// Step 2: Resolve with Slingshot (validates existence + gets PDS)
const resolved = await resolveHandleWithSlingshot(handle);

return resolved; // { did, handle, pds, signing_key }
}

The idea here is that we first check we have a valid handle and only if it's valid (exit early!) we check if the handle actually exists.

I have opted to use Slingshot to resolve the handle since it allows me to skip some steps.

With Slingshot one remote call also gets us some handy details about the users DID and PDS. We'll save these for later as they will come in handy!

3. Returns DID, PDS URL

// Usage
const identity = await validateHandle("user.bsky.social");
// Returns: { did: "did:plc:abc123", handle: "user.bsky.social", pds:
"https://bsky.social", signing_key: "..." }

Creating a custom OAuth client

Now we are going to setup an OAuth client. As I mentioned, if you are on Node.js you should probably use the official client from the atproto library in this step but we are on Deno which does not work with the official ATProto client so I had to create a custom OAuth client. You can skip straight to the Redirect to PDS step if you're using Node.js.

OAuth client initialization

First we create our client.

export class CustomOAuthClient {
private storage: ValTownStorage;
private clientId: string;
private redirectUri: string;

constructor(storage: ValTownStorage) {
// SQLite-backed storage
this.storage = storage;

// Self-referencing client ID
this.clientId = `${BASE_URL}/client-metadata.json`;

// Where PDS will redirect back
this.redirectUri = `${BASE_URL}/oauth/callback`;
}

// the client stores the session
// deals with the PKCE generation
// discovers the users PDS
// and finally creates the authentication URL
// so we can do the PAR request

// I go into more detail on these steps below..
}

The authentication middleware creates a singleton client, the client sets up our storage and the relevant config, and then we can store the session we create in sqlite in a later step.

PKCE generation

Now we generate the code verifier and challenge. This is ideally a step you use the Typescript ATProto library's built-in functions for, but since the AtProto client does not work with Deno we have to do this stuff ourselves.

Generate code verifier (random 32-byte string):

// From custom-oauth-client.ts
private generateCodeVerifier(): string {
const array = new Uint8Array(32); // 32 random bytes
crypto.getRandomValues(array); // Fill with crypto-secure random

// Base64 encode
return btoa(String.fromCharCode(...array))
.replace(/[+/]/g, (match) => match === "+" ? "-" : "_")  //
URL-safe chars
.replace(/=/g, ""); // Remove padding
}
// Result: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

Generate code challenge (SHA256 hash of verifier):

private async generateCodeChallenge(verifier: string): Promise<string>
{
const encoder = new TextEncoder();
const data = encoder.encode(verifier); // Convert to bytes

// SHA256 hash
const digest = await crypto.subtle.digest("SHA-256", data);

// Base64 encode
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/[+/]/g, (match) => match === "+" ? "-" : "_") // URL-safe
.replace(/=/g, ""); // Remove padding
}
// Result: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

Store PKCE data for later verification:

// During authorization URL generation
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);

// Store PKCE verifier and session data
await this.storage.set(`pkce:${state}`, {
codeVerifier,        // Secret - needed for token exchange
authServer,          // PDS authorization server
handle,              // User handle
did: resolved.did,   // User DID
pdsUrl: resolved.pds // PDS endpoint
});

Use challenge in authorization URL:

const authUrl = new URL(authServer.authorizationEndpoint);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
// ... other OAuth params

4. Discover OAuth endpoints

Resolves the user's Personal Data Server (PDS) from their handle. We already saved this in the handle validation step

🔑 Key insight: The PDS (data storage) and authorization server can be different. Slingshot gives you the PDS, and then you need to discover where OAuth actually happens.

Ask PDS "where do I authenticate?":

const resourceMetadataResponse = await fetch(
`${pdsEndpoint}/.well-known/oauth-protected-resource`
);

// Example response from /.well-known/oauth-protected-resource:
{
"resource": "https://bsky.social",
"authorization_servers": [
"https://bsky.social"
],
"scopes_supported": ["atproto", "transition:generic"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://atproto.com"
}

const resourceMetadata = await resourceMetadataResponse.json();
const authorizationServers = resourceMetadata.authorization_servers;

if (!authorizationServers || authorizationServers.length === 0) {
throw new Error("No authorization servers found in PDS metadata");
}

// Use the first (usually only) authorization server
authorizationServerUrl = authorizationServers[0];

Discover OAuth endpoints from authorization server:

const metadataResponse = await fetch(
`${authorizationServerUrl}/.well-known/oauth-authorization-server`
);
const metadata = await metadataResponse.json();

const authorizationEndpoint = metadata.authorization_endpoint;
const tokenEndpoint = metadata.token_endpoint;

return {
pdsEndpoint,           // "https://bsky.social"
authorizationEndpoint, // "https://bsky.social/oauth/authorize"
tokenEndpoint,         // "https://bsky.social/oauth/token"
};

With this information we know exactly where to login.

5. Push authorization request

Now we are going to take the code verifier and code challenge we created earlier and generate the OAuth URL with proper parameters pointing to user's PDS which we found in the previous step.

🔑 Why PAR? AT Protocol requires Pushed Authorization Requests (PAR) for security - the sensitive parameters are pre-registered server-side instead of passed in the URL.

Generate PKCE and prepare OAuth parameters:

// From custom-oauth-client.ts - getAuthorizationUrl()
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);

// Store PKCE data for later verification
await this.storage.set(`pkce:${state || handle}`, {
codeVerifier,
authServer,
handle,
did: resolved.did,
pdsUrl: resolved.pds
});

Push Authorization Request (PAR) - AT Protocol requirement:

// AT Protocol uses PAR (RFC 9126) for security
const parParams = new URLSearchParams({
response_type: "code",

// "https://dropanchor.app/client-metadata.json"
client_id: this.clientId,

// "https://dropanchor.app/oauth/callback"
redirect_uri: this.redirectUri,
scope: "atproto transition:generic",

// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
code_challenge: codeChallenge,
code_challenge_method: "S256",
state: state || "", // Random UUID or mobile redirect data
login_hint: handle, // "user.bsky.social" (helpful for PDS)
});

// Push parameters to authorization server (security requirement)
const parResponse = await fetch(`${authServer}/oauth/par`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: parParams,
});

6. Return request URI

Which returns the request URI, so we can build final authorization URL:

const parResult = await parResponse.json();
// Result: { request_uri: "urn:ietf:params:oauth:request_uri:abc123",
expires_in: 90 }

// Build authorization URL with request_uri from PAR
const authParams = new URLSearchParams({
// "https://dropanchor.app/client-metadata.json"
client_id: this.clientId,
// "urn:ietf:params:oauth:request_uri:abc123"
request_uri: parResult.request_uri,
});

const authUrl = `${authServer}/oauth/authorize?${authParams}`;

return authUrl;
// Result: "https://bsky.social/oauth/authorize?client_id=https://dropanchor.app/client-metadata.json&request_uri=urn:ietf:params:oauth:request_uri:abc123"

7. Redirect to PDS

Now the user is redirected to their own PDS for authentication.

User sees their PDS's login page (not Anchor's)

User enters their password on their own server

PDS validates credentials locally

PDS shows consent screen: "Anchor Location Feed wants to access your data"

User approves the request

// From iron-router.ts - /login route
app.get("/login", async (c) => {
const { handle } = c.req.query();

if (typeof handle !== "string" || !isValidHandle(handle)) {
return c.text("Invalid handle", 400);
}

// Use custom OAuth client (official library has crypto issues in Deno)
try {
console.log(`Starting OAuth authorize for handle: ${handle}`);
const state = crypto.randomUUID();
const url = await c.get("oauthClient").getAuthorizationUrl(handle,
state);
console.log(`Generated authorization URL: ${url}`);
return c.redirect(url);  // <-- THE REDIRECT
} catch (err) {
console.error("OAuth authorize failed:", err);
return c.text("Couldn't initiate login", 400);
}
});

8. User authenticates at PDS

The browser redirects the user to a URL something like:

https://bsky.social/oauth/authorize?client_id=https://dropanchor.app/client-metadata.json&request_uri=urn:ietf:params:oauth:request_uri:abc123

9. PDS redirects back with authorization code

After the user approves, the PDS redirects the browser back to the Anchor backend.

https://dropanchor.app/oauth/callback?code=abc123def&state=uuid-from-step-1

🔑 Key insight: the user never enters their password on Anchor's servers - they authenticate directly with their own PDS (Bluesky, personal server, etc). This is the decentralized authentication model of AT Protocol.

10. Token exchange request

Now we need to exchange the authorization code for access/refresh tokens using DPoP. DPoP is specific OAuth requirement for ATProto since the network does not have a central authority to validate tokens. DPoP ensures that even if your access token is compromised, attackers can't use it without your private key. This is essential for AT Protocol's "trust no one" decentralized model where tokens flow between many independent servers.

OAuth callback handler receives authorization code:

// From iron-router.ts - /oauth/callback
app.get("/oauth/callback", async (c) => {
const params = new URLSearchParams(c.req.url.split("?")[1]);
const oauthSession = await c.get("oauthClient").handleCallback(params);
// ... session creation
});

Retrieve stored PKCE data and generate DPoP keys:

// From custom-oauth-client.ts - handleCallback()
const code = params.get("code");
const state = params.get("state");

// Get stored PKCE data (has the secret code_verifier)
const pkceData = await this.storage.get(`pkce:${state || ""}`);
if (!pkceData) {
throw new Error("Invalid state or expired session");
}

// Generate DPoP keys first (needed for token exchange)
const dpopKeys = await generateDPoPKeyPair();

Create DPoP proof for token exchange:

// Create DPoP proof for token exchange
const tokenUrl = `${pkceData.authServer}/oauth/token`;
const dpopProof = await generateDPoPProof(
"POST",                  // HTTP method
tokenUrl,                // Token endpoint URL
dpopKeys.privateKey,     // DPoP private key
dpopKeys.publicKeyJWK,   // DPoP public key
);

11. Post OAuth token

Exchange authorization code for tokens:

// Exchange code for tokens with DPoP proof
const tokenBody = new URLSearchParams({
grant_type: "authorization_code",
client_id: this.clientId, // "https://dropanchor.app/client-metadata.json"
redirect_uri: this.redirectUri, // "https://dropanchor.app/oauth/callback"
code, // "abc123def" from PDS
code_verifier: pkceData.codeVerifier, // PKCE secret from storage
});

let tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": dpopProof, // AT Protocol DPoP requirement
},
body: tokenBody,
});

Handle DPoP nonce retry (AT Protocol requirement):

// AT Protocol servers may require nonce for DPoP
if (tokenResponse.status === 400) {
const nonce = tokenResponse.headers.get("DPoP-Nonce");
if (nonce) {
// Generate new DPoP proof with nonce
const dpopProofWithNonce = await generateDPoPProof(
"POST",
tokenUrl,
dpopKeys.privateKey,
dpopKeys.publicKeyJWK,
undefined, // no access token yet
nonce      // server-provided nonce
);

// Retry with nonce
tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": dpopProofWithNonce
},
body: tokenBody,
});
}
}

12. Return access & refresh tokens

Extract tokens and create session:

const tokens = await tokenResponse.json();

// OAuth Session = Full AT Protocol data (tokens, keys)
// stored server-side only
const session: OAuthSession = {
did: pkceData.did,
handle: pkceData.handle,
pdsUrl: pkceData.pdsUrl,
accessToken: tokens.access_token, // "at_abc123..." (short-lived)
refreshToken: tokens.refresh_token, // "rt_xyz789..." (long-lived)
dpopPrivateKeyJWK: dpopKeys.privateKeyJWK, // Store for future API calls
dpopPublicKeyJWK: dpopKeys.publicKeyJWK,
tokenExpiresAt: Date.now() + (tokens.expires_in * 1000),
};

// Clean up PKCE data (they are one-time use)
await this.storage.del(`pkce:${state || ""}`);

Store OAuth session

Store OAuth tokens separately (server-side only):

// Store OAuth session in our storage for later use
await valTownStorage.set(
// Key: "oauth_session:did:plc:abc123"
`oauth_session:${oauthSession.did}`,
// Value: { accessToken, refreshToken, dpopKeys, etc }
oauthSession,
);

What gets created:

// Server stores OAuth data in SQLite:
INSERT INTO iron_session_storage VALUES (
'oauth_session:did:plc:abc123',
'{"accessToken":"at_xyz","refreshToken":"rt_abc","dpopPrivateKeyJWK":
{...}}',
1640995200,  -- expires_at
1640908800,  -- created_at
1640908800   -- updated_at
);

13. Set encrypted cookie & redirect

Now that we have the session stored server-side we are going to create an encrypted session cookie with 7-day TTL. This is what will save the user session in the users browser. There are many ways to do this too but I've opted to use the iron session library to secure the session cookie (an idea I stole from the bookhive.buzz implementation ) and I'm using a sliding expiration to make the cookie long lived to the user does not have to login again all the time.

🔑 Key benefits:

Encrypted cookies - Session data is encrypted, not just signed

Sliding expiration - Each API call extends the session lifetime

Separation - Web session (cookie) separate from OAuth tokens (server-side)

HttpOnly - Cookie not accessible to JavaScript (XSS protection)

The cookie only contains the DID, then the server looks up the full OAuth session data when needed.

After successful token exchange, create web session:

// From iron-router.ts - /oauth/callback
// Create encrypted Iron Session cookie
const clientSession = await getIronSession<Session>(c.req.raw, c.res, {
cookieName: "sid",        // Cookie name in browser
password: COOKIE_SECRET,  // Encryption key from env
ttl: 60 * 60 * 24 * 7,    // 7 days TTL with sliding expiration
});

Only store user DID in session and save cookie:

// Session interface (simple - just stores DID)
export interface Session {
did: string;
}

// Set session data and create encrypted cookie
clientSession.did = oauthSession.did;  // "did:plc:abc123..."

// Creates encrypted "sid" cookie in browser
await clientSession.save();

What gets created:

// Browser receives encrypted cookie:
Set-Cookie: sid=encrypted_data_here; HttpOnly; Secure; SameSite=Lax; Max-Age=604800

Using the session for validating requests

Session validation endpoint checks both cookie and OAuth data

app.get("/validate-session", async (c) => {
const session = await getIronSession<Session>(c.req.raw, c.res, {
cookieName: "sid",
password: COOKIE_SECRET,
});

if (!session.did) {
return c.json({ valid: false }, 401);
}
// Check if we have OAuth session data
const oauthSession = await valTownStorage.get(`oauth_session:${session.did}`);

if (!oauthSession) {
return c.json({ valid: false }, 401);
}

// Extend session TTL (sliding expiration)
await session.save();  // Updates cookie expiration

return c.json({ valid: true, did: session.did, handle: oauthSession.handle });
});

With the session data safely saved in the backend, and the users browser cookie, we can now use this when we need API access.

Easy retrieval for API authentication:

// Later, when user makes API calls:
const did = clientSession.did;  // From encrypted cookie: "did:plc:abc123"
const oauthSession = await valTownStorage.get(`oauth_session:${did}`);

// Now you have everything needed for AT Protocol calls:
// - oauthSession.accessToken
// - oauthSession.dpopPrivateKeyJWK
// - oauthSession.pdsUrl

Key-value lookup pattern:

// Clean separation of concerns:
const browserCookie = "sid=encrypted_did_only"; // Client-side
const serverStorage = `oauth_session:${did}` → fullData; // Server-side

// Cookie tells us WHO, storage tells us HOW to authenticate them
const userDID = await getFromCookie();     // "did:plc:abc123"
const authData = await getFromStorage();   // Full OAuth tokens + keys

🔑 Why this setup?

Security - Sensitive tokens never leave the server

Flexibility - Can refresh tokens without touching cookies

Clean separation - Browser identity vs server authentication data

The browser only knows "I am user X" while the server knows "here's how to authenticate as user X."

Finally! Redirect to home

And then we are at the last step, we can now redirect the user to their home view where they will be logged in.

After both sessions are created, redirect user:

// Web callback - set cookies and redirect to home
return c.redirect("/");  // <-- THE WEB REDIRECT

// 3. Dashboard can validate session and load user data:
// Frontend can now call /validate-session to confirm authentication
fetch("/validate-session")
.then(res => res.json())
.then(data => {
if (data.valid) {
console.log(`Authenticated as ${data.handle} (${data.did})`);
// Load user's feed, profile, etc.
}
});

When user hits "/" they now have:

Encrypted "sid" cookie with their DID

Server-side OAuth session with tokens

Ready to make authenticated API calls

And that's it! Admittedly having written this all down this looks like a lot of work, and I guess it is. So hopefully we'll see some more out-of-the-box client options for different runtimes in the near future. In the meantime I hope this article helps you roll your own OAuth solution or just helps you figure out which steps are needed for this to work.

In Part 2 I will go over the mobile endpoints and Swift code I have that make it possible for the Mobile app to also use this authentication setup to make secure API requests via the Anchor backend.

Further reading

atproto/packages/api/README.md at main · bluesky-social/atproto

Social networking technology created by Bluesky. Contribute to bluesky-social/atproto development by creating an account on GitHub.

Slingshot: atproto edge record cache

API Documentation for Slingshot, a firehose-listening atproto edge record and identity cache.

Building End-to-End OAuth for Bluesky: From Cloudflare Workers to iOS

Technical guide on building OAuth 2.0 + DPoP authentication for Bluesky, covering Cloudflare Workers backend challenges (CryptoKey serialization) and iOS implementation (extending ATProtoKit), with solutions for nonce handling, token refresh, and key management.

GitHub - nperez0111/bookhive: Goodreads but better. Built on top of Blue Sky.

Goodreads but better. Built on top of Blue Sky. Contribute to nperez0111/bookhive development by creating an account on GitHub.