AT Proto OAuth with Progressive Scope Upgrade: a How-To

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-node handles OAuth 2.1 + DPoP + PKCE
  • @atproto/api Bluesky API client (posting, profiles)
  • @atproto-labs/simple-store-memory in-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

  1. Login: User signs in with scope: "atproto" → gets identity, can use your app
  2. Use the app: Comments/posts go through your app's service account
  3. Upgrade prompt: "Want to post as yourself? Click here"
  4. Revoke + re-auth: Backend revokes old session, starts fresh with scope: "atproto transition:generic"
  5. Post as user: Restore session → new Agent(oauthSession)agent.post()

Available Scopes

ScopeAccess
atprotoIdentity only: DID + profile read
atproto transition:genericFull: post, like, follow, read preferences
atproto transition:emailAccess to account email
atproto transition:chat.bskyChat/DM access

Check your PDS's /.well-known/oauth-authorization-server for scopes_supported.

DB Fields You Need

FieldTypePurpose
didstring, uniquePermanent identity (e.g. did:plc:abc123)
bluesky_handlestringMutable: update on each login
atproto_scopestringCurrent granted scope
atproto_sessiontext/jsonSerialized 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.

Share on Bluesky