Skip to content
Social SDK megaphone markSocial SDK
Esc
navigateopen⌘Jpreview
On this page

Bluesky

Connect Bluesky accounts with AT Protocol OAuth, publish records, and use typed AT Protocol operations with explicit reconciliation.

The direct Bluesky adapter accepts an access JWT or a DPoP-bound OAuth session, together with a DID and HTTPS service URL. For user-facing sign-in, use blueskyOAuth from @opencoredev/social-sdk/server. It implements the AT Protocol OAuth profile: handle and DID resolution, PDS and authorization server discovery, pushed authorization requests, PKCE, DPoP-bound tokens, the iss callback check, and identity verification after the token exchange. It has no runtime dependencies. The official @atproto/oauth-client-node client remains a supported alternative.

Setup

Publish client metadata

Serve a client metadata document at an HTTPS URL. That URL is your clientId. Build the document with blueskyOAuthClientMetadata so the required fields are present. A confidential client adds an ES256 public key through jwks or jwksUri and keeps the private key in a server-side secret store. For local development, blueskyLoopbackClientId returns an http://localhost client ID, which is always a public client.

Start the connection with a login hint

Pass the handle, DID, or server URL the user typed as loginHint to ConnectionManager.begin. A handle is resolved through DNS and then HTTPS and must match its DID document. A server URL such as https://bsky.social starts a flow where the user picks the account at the server. Set defaultServer on the provider to use a server when no hint is given.

Store the session

On completion the provider checks the callback iss, exchanges the code with DPoP, and confirms that the returned DID’s PDS names the same authorization server. The verified session goes to sessionSink. It contains the access token, refresh token, and private DPoP key, so encrypt it and scope reads to the tenant.

Configure the adapter

Load the stored session with parseBlueskyOAuthSession and pass blueskyOAuthTransport(session) as the adapter’s session. Never put tokens or DPoP keys in browser code.

import { ConnectionManager, blueskyOAuth } from "@opencoredev/social-sdk/server";

const provider = blueskyOAuth({
  clientId: "https://app.example.com/oauth/client-metadata.json",
  clientKey: { kid: "key-1", privateJwk: signingKey }, // omit for a public client
  redirectUri: "https://app.example.com/oauth/bluesky/callback",
  scope: "atproto transition:generic",
  sessionSink: {
    async save({ account, session, attempt }) {
      // Encrypt and store the session for attempt.tenantId and account.ref.accountId.
    },
  },
});

const started = await manager.begin({
  backend: "direct",
  tenantId,
  principalId,
  platforms: ["bluesky"],
  redirectUri: "https://app.example.com/oauth/bluesky/callback",
  allowedRedirectUris: ["https://app.example.com/oauth/bluesky/callback"],
  provider,
  loginHint: "alice.bsky.social",
});

Then build the adapter from the stored session:

import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
import { blueskyOAuthTransport, parseBlueskyOAuthSession } from "@opencoredev/social-sdk/server";

const session = parseBlueskyOAuthSession(await loadDecryptedSession(accountId));
const transport = blueskyOAuthTransport(session);

const social = createSocial({
  backend: bluesky({
    auth: { service: transport.service, did: transport.did },
    session: transport,
  }),
});

const account = connectedAccountRef({
  backend: "default",
  platform: "bluesky",
  accountId: session.did,
});

The transport sends each request to the session’s PDS with Authorization: DPoP and a fresh proof bound to the access token. When the PDS asks for a new DPoP nonce, it sends the request once more with that nonce. It does not refresh tokens. Call refreshBlueskyOAuthSession(session, { clientId, clientKey }) before expiresAt. Refresh re-resolves the account’s DID and fails if its PDS now names a different authorization server. Refresh tokens rotate, so save the result with a compare-and-set write. A reused or expired refresh token returns reconnect_required.

An access JWT from an app password still works:

const social = createSocial({
  backend: bluesky({
    auth: {
      service: process.env.BLUESKY_SERVICE!,
      did: process.env.BLUESKY_DID!,
      accessJwt: process.env.BLUESKY_ACCESS_JWT!,
    },
  }),
});

The adapter sends relative XRPC paths to session.fetchHandler, so a session from the official client also works. examples/snippets/bluesky-oauth.ts and examples/snippets/bluesky-sdk-session.ts show that route. Do not extract an OAuth token into accessJwt; OAuth tokens are DPoP-bound and fail as bearer tokens.

Examples

Publish a text post

The adapter validates grapheme length and creates rich-text URL facets automatically.

const result = await social.posts.publish({
  targets: [{ account }],
  content: { text: "A Bluesky post with a link https://example.com" },
});

Publish with images

The adapter enforces a 10 MiB image limit and a four-image maximum. HTTPS remote image hosts must pass the configured allowMediaHost policy. A stream is bounded while read, but the adapter needs a replayable source for retries.

const result = await social.posts.publish({
  targets: [{ account }],
  content: {
    text: "Screenshots attached",
    media: [
      { kind: "image", source: { kind: "https-url", url: "https://cdn.example.com/a.png" } },
      { kind: "image", source: { kind: "https-url", url: "https://cdn.example.com/b.png" } },
    ],
  },
});

Languages and mentions

Set options.languages to at most three BCP 47 language tags. The adapter validates and canonicalizes them, then writes the native langs field. For a linked mention, supply an already-resolved DID and UTF-8 byte offsets in options.mentions. It performs no automatic handle lookup.

const content = { text: "🙂 @alice.example https://example.com" };
const options = {
  languages: ["en-US"],
  mentions: [{ byteStart: 5, byteEnd: 19, did: "did:plc:alice" }],
};

The emoji occupies four UTF-8 bytes, so the mention begins at byte five after the space. The DID must come from a trusted identity lookup; plain @handle text alone is not resolved into a mention. Preparation rejects split character boundaries, overlapping mention/link spans, and malformed language tags. The managed adapters reject these options because they have no verified mapping for them.

Like a post

adapter.native.likePost accepts an acting account and the target post’s native { uri, cid }. The target may belong to another author.

import { connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";

const adapter = bluesky({
  backend: "direct",
  auth: { service, accessJwt, did },
});

const like = await adapter.native!.likePost({
  account: connectedAccountRef({ backend: "direct", platform: "bluesky", accountId: did }),
  post: { uri: postUri, cid: postCid },
});
// Persist like.uri: unlikePost accepts only a like-record URI
// belonging to the configured DID, not the target post URI.

It creates an app.bsky.feed.like record in the acting account’s repository and returns that record’s URI/CID. Native calls bypass the client’s tenant authorization and concurrency middleware; authorize the acting account before invoking them. Mutations use bounded transport and do not retry a lost response; reconcile uncertain acceptance before submitting again.

Read threads natively

Bluesky replies preserve the native AT URI and CID through the adapter’s native post metadata. Native reads and thread reads are available through social.native('default', { acknowledgeUnsafe: true }); this path bypasses normalized middleware and must be wrapped by your tenant policy.

Search posts natively

native.searchPosts calls app.bsky.feed.searchPosts. Supply a non-empty query and, optionally, sort (latest or top), since, until, mentions, author, lang, domain, url, and tags. Pages contain provider post objects, an optional cursor, and an optional hitsTotal. Bluesky documents the cursor as best effort; it may not traverse every hit. The endpoint accepts 1–100 results per request (default 25), and time bounds use ISO dates or datetimes with an inclusive since and exclusive until.

The normalized search facade supports the provider’s recent horizon. Bluesky does not define an all horizon for this endpoint, so scope: "all" is rejected before a request is sent.

Read and change relationships

The normalized graph facade maps to AT Protocol profile and graph procedures. Relationship pages use app.bsky.graph.getFollowers, getFollows, getBlocks, and getMutes; follow and block create repository records, while unfollow and unblock delete those records. Mute and unmute use muteActor and unmuteActor.

const profile = await social.graph.getProfile(account, { handle: "alice.bsky.social" });
const followers = await social.graph.listRelationships(account, { kind: "followers", limit: 50 });
await social.graph.follow(profile.ref);
await social.graph.mute(profile.ref);

Search actors, likes, lists, and reports

Use native.searchActors or native.searchActorsTypeahead for actor discovery. Likes use app.bsky.feed.getLikes and getActorLikes for reads and repository records for writes. List creation and item changes use app.bsky.graph.list and listitem records; list views and list mutes use app.bsky.graph.muteActorList and unmuteActorList. List blocks are app.bsky.graph.listblock records that the adapter creates and removes with repository procedures. Moderation reports call com.atproto.moderation.createReport and require an authenticated moderation service.

Receive events

Bluesky has no webhooks, so webhooks.verify is unsupported-by-platform and the adapter has no webhooks member. Subscribe to the relay firehose (com.atproto.sync.subscribeRepos) or Jetstream over WebSocket instead. See Consuming the firehose.

Native operations

The typed native module covers reposts and quote posts, deletion, graph relationships, likes, actor search, lists, moderation reports, notification reads and seen state, profile reads/updates, feed discovery, search, and chat.bsky conversation/message operations. Video upload is not supported yet. Chat responses remain provider-controlled moderation states; persist native references and reconcile explicitly. Bluesky chat consent and moderation requirements still apply.

deleteComment({ account, commentId, context }) takes a reply’s at:// URI and calls com.atproto.repo.deleteRecord for the app.bsky.feed.post record. It only deletes records in the authenticated account’s repository. A URI from another DID raises unauthorized before any request is sent.

Limits and requirements

  • The adapter returns the record URI/CID as the platform reference when creation succeeds. Network failures after submission are ambiguous; reconcile before retrying.
  • Published posts cannot be edited, so posts.update is declared unsupported-by-platform.
  • The built-in OAuth transport does not refresh tokens on its own. Refresh with refreshBlueskyOAuthSession, or let the official client’s session handle it if you use that client.
  • If a token exchange or refresh returns a usable token but the response then fails validation (missing DPoP-Nonce, wrong token_type, missing atproto scope, bad sub) or identity verification fails, the helper tries once to revoke the new tokens at the authorization server’s revocation_endpoint, when the server advertises one. It revokes the refresh token, or the access token if no refresh token was issued. This is a single best-effort request with no retry. A failed revocation is ignored, and the original error is thrown. The helper does not revoke tokens in any other case, such as when an account disconnects.
  • Handle, DID, PDS, and authorization server URLs come from documents other parties control, so every request passes an egress check first. Only HTTPS is allowed. localhost names, URLs with credentials, and IP-literal hosts in loopback, private, link-local, carrier-grade NAT, multicast, documentation, and other reserved IPv4 and IPv6 ranges are rejected with unauthorized, including IPv4-mapped IPv6 addresses. blueskyOAuthTransport applies the same check to the stored pdsUrl.
  • The egress check does not resolve DNS, so a public hostname that resolves to a private address still passes. On servers that can reach internal networks, pass assertEgressAllowed to blueskyOAuth, refreshBlueskyOAuthSession, and blueskyOAuthTransport, and inject a fetch that pins resolved addresses. The hook runs before every request and redirect hop; throwing from it rejects the request.
  • Handle resolution through https://<handle>/.well-known/atproto-did follows up to three HTTP redirects, as the handle specification allows. Each hop is followed manually and checked again. OAuth metadata requests do not follow redirects.
  • Pushed authorization, token, and refresh responses must carry a DPoP-Nonce header, as the AT Protocol OAuth specification requires; a response without one fails with upstream_failure. PDS responses without a nonce are returned as they are, because the PDS has already processed the request and rejecting it would hide the outcome of a write.
  • blueskyOAuthClientMetadata publishes only EC P-256 public keys. It rejects any key with private members (d, p, q, dp, dq, qi, oth, k), any other key type or curve, and any alg other than ES256 or use other than sig. Unknown members are dropped from the published key.
  • Public clients get sessions of about two weeks and must reconnect after that. Confidential clients can refresh for longer, as set by the authorization server.
  • App-password policy and application account membership remain application responsibilities.
  • These are fixture-tested protocol operations, including the OAuth flow against mocked servers. No live account verification is recorded in this repository.

Last updated on September 24, 2026