Web Authentication Guide#
This guide covers implementing AT Protocol OAuth for web applications using
@tijs/atproto-oauth.
Overview#
Web authentication uses a standard OAuth 2.0 flow with PKCE:
- User clicks "Login" and enters their Bluesky handle
- Your server redirects to the user's authorization server
- User approves access
- Authorization server redirects back with an authorization code
- Your server exchanges the code for tokens
- Session cookie is set for subsequent requests
Setup#
Installation#
import { createATProtoOAuth } from "jsr:@tijs/atproto-oauth";
import { SQLiteStorage, valTownAdapter } from "jsr:@tijs/atproto-storage";
Configuration#
const oauth = createATProtoOAuth({
baseUrl: "https://myapp.example.com",
appName: "My App",
cookieSecret: Deno.env.get("COOKIE_SECRET")!, // At least 32 characters
storage: new SQLiteStorage(valTownAdapter(sqlite)),
sessionTtl: 60 * 60 * 24 * 14, // 14 days (max for public clients)
logoUri: "https://myapp.example.com/logo.png", // Optional
policyUri: "https://myapp.example.com/privacy", // Optional
logger: console, // Optional, for debugging
});
Route Handlers#
Mount these routes in your web framework. Examples shown for Hono:
Login Route#
Starts the OAuth flow. Accepts handle as a query parameter.
app.get("/login", (c) => oauth.handleLogin(c.req.raw));
Request: GET /login?handle=alice.bsky.social
The user is redirected to their authorization server (e.g., bsky.social).
Callback Route#
Handles the OAuth callback after user authorization.
app.get("/oauth/callback", (c) => oauth.handleCallback(c.req.raw));
On success, sets a session cookie and redirects to / (or a custom path).
Client Metadata Route#
Required by AT Protocol OAuth. Serves your app's OAuth client metadata.
app.get("/oauth-client-metadata.json", () => oauth.handleClientMetadata());
Logout Route#
Clears the session cookie and OAuth tokens.
app.post("/api/auth/logout", (c) => oauth.handleLogout(c.req.raw));
Protecting Routes#
Use getSessionFromRequest() to check authentication:
app.get("/api/profile", async (c) => {
const { session, setCookieHeader, error } = await oauth.getSessionFromRequest(
c.req.raw,
);
if (!session) {
return c.json({ error: error?.message || "Not authenticated" }, 401);
}
// Make authenticated API call to user's PDS
const response = await session.makeRequest(
"GET",
`${session.pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${session.did}`,
);
const profile = await response.json();
// Important: refresh the session cookie
const res = c.json(profile);
if (setCookieHeader) {
res.headers.set("Set-Cookie", setCookieHeader);
}
return res;
});
Session Object#
The session object provides:
interface SessionInterface {
did: string; // User's DID (e.g., "did:plc:abc123")
handle?: string; // User's handle (e.g., "alice.bsky.social")
pdsUrl: string; // User's PDS URL
accessToken: string; // Current access token
refreshToken?: string; // Refresh token (if available)
// Make authenticated requests with automatic DPoP handling
makeRequest(
method: string,
url: string,
options?: RequestInit,
): Promise<Response>;
}
Error Handling#
When session is null, check error for details:
error?: {
type: "NO_COOKIE" | "INVALID_COOKIE" | "SESSION_EXPIRED" | "OAUTH_ERROR" | "UNKNOWN";
message: string;
details?: unknown;
}
Custom Redirect After Login#
Pass a redirect query parameter to return users to a specific page:
// Start login with redirect
const loginUrl = `/login?handle=${handle}&redirect=/dashboard`;
Only relative paths starting with / are allowed for security.
Session Endpoint#
You'll typically want a session check endpoint for your frontend:
app.get("/api/auth/session", async (c) => {
const { session, setCookieHeader } = await oauth.getSessionFromRequest(
c.req.raw,
);
if (!session) {
return c.json({ authenticated: false });
}
const res = c.json({
authenticated: true,
did: session.did,
handle: session.handle,
});
if (setCookieHeader) {
res.headers.set("Set-Cookie", setCookieHeader);
}
return res;
});
Frontend Integration#
Login Form#
<form action="/login" method="get">
<input type="text" name="handle" placeholder="alice.bsky.social" required />
<button type="submit">Sign in with Bluesky</button>
</form>
Check Authentication (JavaScript)#
async function checkAuth() {
const response = await fetch("/api/auth/session", {
credentials: "include",
});
const data = await response.json();
if (data.authenticated) {
console.log(`Logged in as ${data.handle}`);
} else {
console.log("Not logged in");
}
}
Logout#
async function logout() {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
});
window.location.href = "/";
}
Security Considerations#
-
Cookie Secret: Use a strong, random secret of at least 32 characters. Store it securely (environment variable, secrets manager).
-
HTTPS: Always use HTTPS in production. The session cookie has
Secureflag set. -
Session TTL: AT Protocol spec limits public client sessions to 14 days maximum.
-
CORS: If your API is on a different domain, configure CORS appropriately. Session cookies require
credentials: "include"on fetch requests.
Complete Example#
See the Hono example in the main README for a complete working setup.
Resources#
AT Protocol Documentation#
- OAuth Specification - Full OAuth spec for AT Protocol
- OAuth Introduction - Overview of OAuth patterns and app types
- Building Applications Guide - Quick start guide for AT Protocol apps
Example Implementations#
- Go OAuth Web App - Official Bluesky web app example in Go
- Python OAuth Web App - Official Bluesky web app example in Python