Skip to content

Repository files navigation

better-auth-bsky

A better-auth plugin for Bluesky / AT Protocol OAuth. Sign in with Bluesky, persist atproto sessions, and make authenticated API calls on behalf of users.

Features

  • Sign in with Bluesky via AT Protocol OAuth (PKCE + DPoP)
  • Localhost development without tunnels (loopback client support)
  • Auto-hosted client metadata and JWKS endpoints
  • DB-backed OAuth state and session stores
  • Account linking for users who are already signed in
  • Session restoration for making authenticated atproto API calls
  • Profile sync (handle, display name, avatar, bio, banner)

Install

npm install better-auth-bsky
# or
bun add better-auth-bsky

Quick Start

1. Generate a private key (production)

openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem

For localhost development, no key is needed.

2. Server plugin

import { betterAuth } from "better-auth";
import { atprotoAuth } from "better-auth-atproto";

export const auth = betterAuth({
  // baseURL is required
  baseURL: "https://myapp.com",
  plugins: [
    atprotoAuth({
      privateKey: process.env.BSKY_PRIVATE_KEY, // contents of ec-private.pem
    }),
  ],
});

3. Run migrations

The plugin adds columns to the user table and creates two new tables. Run migrations after adding the plugin:

npx auth migrate
# or
npx auth generate

4. Client plugin

import { createAuthClient } from "better-auth/client";
import { atprotoAuthClient } from "better-auth-bsky/client";

export const authClient = createAuthClient({
  plugins: [atprotoAuthClient()],
});

5. Sign in

await authClient.signIn.bsky({
  handle: "thepope.dev",
  callbackURL: "/dashboard",
});

Configuration

atprotoAuth({
  // PEM-encoded ES256 private key.
  // Required for production. Optional for localhost.
  privateKey: process.env.BSKY_PRIVATE_KEY,

  clientMetadata: {
    // Name shown on the authorization screen.
    clientName: "My App",
    // OAuth scopes. Default: "atproto transition:generic"
    scope: "atproto transition:generic",
  },

  // Customize how the atproto profile maps to better-auth user fields.
  mapProfileToUser: (profile) => ({
    name: profile.displayName || `@${profile.handle}`,
    image: profile.avatar,
  }),

  // Prevent new user creation. Only existing users can sign in.
  disableSignUp: false,
});

Localhost Development

For local development, no private key or tunnel is needed. The plugin uses AT Protocol's loopback client mode automatically when baseURL is http://localhost:* or http://127.0.0.1:*.

export const auth = betterAuth({
  baseURL: "http://localhost:3000",
  plugins: [
    atprotoAuth(), // no privateKey needed
  ],
});

Production Setup

For production deployments, the plugin auto-hosts two discovery endpoints that the AT Protocol PDS needs to verify your app's identity:

Endpoint URL
Client Metadata {baseURL}/api/auth/bsky/client-metadata.json
JWKS {baseURL}/api/auth/bsky/jwks.json

These are served automatically. No extra routes needed.

Database Schema

The plugin extends the user table and adds two new tables:

User table extensions

Column Type Description
bskyDid string (unique) AT Protocol DID (did:plc:...)
bskyHandle string Bluesky handle (thepope.bsky.social)
bskyBio string Profile description
bskyBanner string Banner image URL

bskyState table

Ephemeral storage for in-flight OAuth requests (auto-expires after 10 minutes).

Column Type
key string (unique)
state string (JSON)
expiresAt date

bskySession table

Persistent storage for atproto OAuth sessions (tokens, DPoP keys).

Column Type
did string (unique)
session string (JSON)
userId string (FK -> user.id, cascade)
updatedAt date

API

Endpoints

Method Path Auth Description
GET /bsky/client-metadata.json No OAuth client metadata for PDS discovery
GET /bsky/jwks.json No Public JWKS for private_key_jwt auth
POST /bsky/sign-in No Initiate sign-in. Body: { handle, callbackURL? }
GET /bsky/callback No OAuth callback (handled automatically)
GET /bsky/session Yes Get atproto session status + fresh profile
POST /bsky/restore Yes Restore atproto session (lightweight check)

Client Actions

// Sign in with Bluesky handle
await authClient.signIn.bsky({
  handle: "thepope.dev",
  callbackURL: "/dashboard", // where to redirect after auth
});

// Get atproto session status and fresh profile data
const { data } = await authClient.bsky.getSession();
// { active: true, did: "did:plc:...", handle: "thepope.dev", ... }

// Lightweight session check (no profile fetch)
const { data } = await authClient.bsky.restore();
// { active: true, did: "did:plc:..." }

Account Linking

If a user is already signed in with another provider (email, Google, etc.) and then signs in with Bluesky, the plugin automatically links the Bluesky account to their existing user rather than creating a duplicate.

This respects better-auth's account.accountLinking.enabled setting. If account linking is disabled in your config, the plugin will return a FORBIDDEN error instead of linking.

Security

The plugin applies rate limiting to sensitive endpoints by default:

Endpoint Window Max Requests
/bsky/sign-in 60s 5
/bsky/callback 60s 10

These limits protect against brute-force handle resolution and callback replay. Metadata endpoints (/bsky/client-metadata.json, /bsky/jwks.json) are not rate limited.

The disableSignUp option prevents new user creation, restricting the plugin to sign-in for existing users and account linking only:

atprotoAuth({
  disableSignUp: true, // returns FORBIDDEN for unknown DIDs
});

How It Works

  1. User enters their Bluesky handle and clicks sign in
  2. The plugin resolves the handle to a DID, discovers their PDS authorization server
  3. User is redirected to their PDS to authenticate (OAuth 2.0 + PKCE + DPoP)
  4. PDS redirects back to /bsky/callback
  5. Plugin exchanges the code for tokens, fetches the user's profile
  6. Creates or links the better-auth user/account, sets the session cookie
  7. Redirects to the app's callbackURL

The atproto OAuth session (including DPoP-bound tokens) is persisted in the bskySession table, allowing token refresh and authenticated API calls via session restoration.

License

MIT

About

atproto oauth plugin for better-auth

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages