AT Proto OAuth with Progressive Scope Upgrade: a How-To
The Goal: Progressive Permissions
We wanted Bluesky login for our Ghost blog with minimal permissions at first. When someone signs in, we only ask for identity (atproto scope). They can read and comment right away.
But if they want their comments to post as themselves on Bluesky, they need write access (atproto transition:generic scope). We show a small prompt: "Your comments post as this blog on Bluesky. Post as @yourhandle instead?"
This is how permissions should work. Don't scare people with write access at login. Let them opt in when they see the value.
The Stack
@atproto/oauth-client-nodehandles OAuth 2.1 + DPoP + PKCE@atproto/apiBluesky API client (posting, profiles)@atproto-labs/simple-store-memoryin-memory store for auth state- Any Node.js backend (we used Ghost CMS, but the pattern works anywhere)
All three packages are ESM-only. If your app is CommonJS, use await import().
Step 1: Client Metadata
Your app must serve a JSON file at a public URL. The user's PDS (Personal Data Server) fetches this to validate your OAuth client. This URL becomes your client_id.
{
"client_id": "https://yourapp.com/oauth/client-metadata.json",
"client_name": "Your App",
"client_uri": "https://yourapp.com",
"redirect_uris": ["https://yourapp.com/oauth/callback"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}Advertise the maximum scope you'll ever need in the metadata. You'll request less at login time.
Step 2: Initialize the Client
const { NodeOAuthClient } = await import('@atproto/oauth-client-node');
const { SimpleStoreMemory } = await import('@atproto-labs/simple-store-memory');
const oauthClient = new NodeOAuthClient({
clientMetadata: { /* same as your client-metadata.json */ },
stateStore: new SimpleStoreMemory({ max: 100, ttl: 600000 }),
sessionStore: yourDbStore // MUST be persistent — see below
});Critical: The stateStore can be in-memory (it's short-lived, 10 min TTL for in-progress auth flows). But the sessionStore must be persistent (database, Redis, etc.) or sessions vanish on server restart and users lose their write access.
The session store interface is simple: get(did), set(did, value), del(did).
Step 3: Login with Minimal Scope
// POST /oauth/authorize
app.post('/oauth/authorize', async (req, res) => {
const { handle, scope = 'atproto' } = req.body;
const url = await oauthClient.authorize(handle, { scope });
res.json({ url: url.toString() });
});
// GET /oauth/callback
app.get('/oauth/callback', async (req, res) => {
const { session } = await oauthClient.callback(
new URLSearchParams(req.query)
);
// GOTCHA: session.scope is undefined!
// You MUST use getTokenInfo() to read the actual scope
const tokenInfo = await session.getTokenInfo();
const scope = tokenInfo.scope || 'atproto';
const did = session.sub;
// Resolve the user's profile
const { BskyAgent } = await import('@atproto/api');
const agent = new BskyAgent({ service: 'https://public.api.bsky.app' });
const profile = await agent.getProfile({ actor: did });
// Create/update your user record
await upsertUser({
did,
handle: profile.data.handle,
scope,
displayName: profile.data.displayName,
avatarUrl: profile.data.avatar
});
// Set session, redirect
});Gotcha #1: session.scope is always undefined. The scope lives in the token set, not on the session object. You must call session.getTokenInfo().
Step 4: Upgrade Scope
When the user wants write access, now we request a higher scope. Possibly because of the error in how we read scope from the session object, we originally thought that asking for prompt: "consent" didn't work, so for now we have implemented with a revoke + reauth. It may work to request upgraded scope, will revisit soon.
For now, this works: revoke the existing session, re-authorize fresh.
// In your authorize endpoint, when scope upgrade is requested:
if (scope.includes('transition:generic')) {
const member = await findUserByHandle(handle);
if (member?.did) {
// Restore and revoke the existing session
const existingSession = await oauthClient.restore(member.did);
if (existingSession) {
await existingSession.signOut(); // hits PDS revocation endpoint
}
}
}
// Then authorize fresh with the higher scope
const url = await oauthClient.authorize(handle, { scope });
The user gets redirected to Bluesky, approves the wider scope, comes back. From their perspective it's the same flow as a normal login.
Step 5: Post as the User
Once they've granted transition:generic, you can restore their OAuth session and post on their behalf:
const { Agent } = await import('@atproto/api');
const oauthSession = await oauthClient.restore(did);
const agent = new Agent(oauthSession);
await agent.post({
text: 'Posted via OAuth!',
reply: replyRef // optional, for threading
});Gotcha #2: You must use Agent, not BskyAgent. BskyAgent requires a service URL and is designed for password-based auth. Agent accepts a SessionManager, and OAuthSession already implements that interface. Just pass it directly: new Agent(oauthSession).
We tried five different approaches before landing on this:
new BskyAgent({service}); agent.session = oauthSession→ "Not logged in"new BskyAgent({did, fetchHandler: dpopFetch})→ "Invalid URL"new Agent({did, fetchHandler: dpopFetch})→ "Authentication Required"new Agent({did, fetchHandler: wrappedDpopFetch})→ "Authentication Required"new Agent(oauthSession)→ works!
Step 6: Lazy Init the OAuth Client
If your OAuth client initializes lazily (e.g., on first login request), any code that tries to restore a session after a server restart will fail - the client is null because nobody has logged in yet.
async function restoreSession(did) {
if (!oauthClient) {
await initOAuthClient(); // init on demand
if (!oauthClient) return null;
}
return await oauthClient.restore(did);
}The Complete Flow
- Login: User signs in with
scope: "atproto"→ gets identity, can use your app - Use the app: Comments/posts go through your app's service account
- Upgrade prompt: "Want to post as yourself? Click here"
- Revoke + re-auth: Backend revokes old session, starts fresh with
scope: "atproto transition:generic" - Post as user: Restore session →
new Agent(oauthSession)→agent.post()
Available Scopes
| Scope | Access |
|---|---|
atproto | Identity only: DID + profile read |
atproto transition:generic | Full: post, like, follow, read preferences |
atproto transition:email | Access to account email |
atproto transition:chat.bsky | Chat/DM access |
Check your PDS's /.well-known/oauth-authorization-server for scopes_supported.
DB Fields You Need
| Field | Type | Purpose |
|---|---|---|
did | string, unique | Permanent identity (e.g. did:plc:abc123) |
bluesky_handle | string | Mutable: update on each login |
atproto_scope | string | Current granted scope |
atproto_session | text/json | Serialized session for oauthClient.restore() |
The DID is the stable identifier. Handles can change -never key on handle.
Open Question
We asked the Bluesky team: is there a way to upgrade scope without revoke + re-auth? prompt: "consent" is advertised as supported but didn't work for us. The revoke approach works, but it'd be cleaner if the PDS could just grant additional scope on re-consent.
Source Code
Our implementation is in the Cooperation-org/Zombie repo. All AT Proto code is clearly separated: search for atproto-oauth or bluesky-sync in the ghost/core/core/server/services/ directory.
Why We Forked
Ghost doesn't have a plugin system for this kind of deep integration. AT Protocol OAuth needs routes in the members app, new columns on the members and posts_meta tables, changes to the comments UI React app, and hooks in the boot sequence. There's no way to add bidirectional Bluesky comment sync as a theme or external integration; it touches the data layer, the API serializers, the admin UI, and the frontend comments bundle.
We'd love for Ghost to adopt this upstream. The AT Proto code is cleanly separated into services/atproto-oauth/ and services/bluesky-sync/, and the schema changes are additive (new nullable columns, no breaking changes). If the Ghost team wants to pull any of this in, its here for you: https://github.com/Cooperation-org/Zombie.