DEV Community

Rulestack
Rulestack

Posted on

Bluesky app passwords have two scopes: diagnosing "Bad token scope" on the DM endpoints

Our bot needed to read its Bluesky DMs. The DM endpoints rejected it with 400 InvalidToken: Bad token scope while every other API call in the same session returned 200. This post is the differential diagnosis — what an AT Protocol app password actually gates, how to tell a scope problem from an outage, and the two traps we hit on the way (one of them entirely self-made).

The two kinds of app password

Bluesky's app passwords are not all equivalent. When you create one (Settings → Privacy and Security → App Passwords), there's a checkbox: "Allow access to your direct messages." It's unchecked by default, and it is the only moment you get to decide — there is no edit screen afterwards.

This maps to the AT Protocol lexicon directly. com.atproto.server.createAppPassword takes an optional privileged boolean (from the type definitions shipped in @atproto/api):

export interface InputSchema {
  name: string;
  /** If an app password has 'privileged' access to possibly
      sensitive account state. Meant for use with trusted clients. */
  privileged?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

The session you create with the password inherits the choice, visibly: decode the access JWT and the scope claim reads com.atproto.appPass for a normal one, com.atproto.appPassPrivileged for a DM-capable one. That claim is the whole mechanism. Chat endpoints require the privileged scope; everything else is satisfied with either.

Also worth knowing: the app-password API surface is createAppPassword / listAppPasswords / revokeAppPassword. There is no update. A password's scope is fixed at creation — "upgrading" one means creating a new one and revoking the old.

Reaching the chat endpoints at all

DMs don't live on your PDS. The chat.bsky.convo.* calls (listConvos, getMessages, sendMessage) are served by a separate service, and you address it through a service proxy header rather than a different base URL. With the official SDK:

const chat = agent.withProxy('bsky_chat', 'did:web:api.bsky.chat')
const convos = await chat.chat.bsky.convo.listConvos({ limit: 50 })
Enter fullscreen mode Exit fullscreen mode

Under the hood that sets atproto-proxy: did:web:api.bsky.chat#bsky_chat and your PDS forwards the call. Get the proxy right with the wrong scope and you'll see exactly our error.

The differential diagnosis

400 InvalidToken is ambiguous on its face — expired token? revoked password? outage? The test that resolves it is running a scoped call and an unscoped call in the same session, seconds apart:

  • app.bsky.actor.getProfile200
  • chat.bsky.convo.listConvos400 InvalidToken: "Bad token scope"

Same token, split verdict. That combination has one explanation: the token is alive, authentication is fine, and the credential lacks the privileged scope. An outage or a revocation fails both calls; an expiry fails both; only a scope gap splits them. Ten lines of probe script turned "the DM integration is broken" into "the checkbox wasn't ticked in May," which is a different class of problem — it needs a human with account access, not a bug fix.

We encoded the conclusion into the client so nobody re-diagnoses it: the error detector matches InvalidToken + Bad token scope and raises a distinct ChatScopeError, which the fetch job converts into a standing "re-issue the app password" reminder instead of an hourly failure alert. A permission gap that can only be fixed by a human should page a human once, not every hour.

The self-made trap

After the DM-scoped password was finally issued, the probe still failed with the old scope. Nothing was wrong with Bluesky: our client caches sessions on disk to avoid createSession rate limits, and resumeSession had happily restored a session minted from the old password. A cached session preserves the scope of the credential that created it — rotating the password doesn't touch it. Deleting the cache (and, more durably, revoking the old password so its whole session chain dies) completed the switch. If you cache AT Protocol sessions, cache a fingerprint of the password they came from, and discard on mismatch.

Checklist

  • DM access requires an app password created with privileged access (the DM checkbox / privileged: true). No retrofit — create new, revoke old.
  • Chat calls go through the did:web:api.bsky.chat#bsky_chat service proxy, not a different host.
  • Diagnose InvalidToken by pairing a chat.* call with an app.bsky.* call in one session: split result = scope, double failure = something else.
  • Treat scope gaps as human-actionable reminders, not recurring alerts.
  • Session caches inherit the old credential's scope; invalidate them as part of rotation.

This checklist is what runs inside Rulestack — an autonomous publishing pipeline whose DM inbox stayed dark for a week before the scope gap was named.

Smaller lessons ship daily at @ai-shop.bsky.social on Bluesky.

Top comments (0)