better-auth-atproto
v0.1.0
Published
atproto (bluesky) oauth plugin for better-auth
Maintainers
Readme
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-bskyQuick Start
1. Generate a private key (production)
openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pemFor 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 generate4. 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
- User enters their Bluesky handle and clicks sign in
- The plugin resolves the handle to a DID, discovers their PDS authorization server
- User is redirected to their PDS to authenticate (OAuth 2.0 + PKCE + DPoP)
- PDS redirects back to
/bsky/callback - Plugin exchanges the code for tokens, fetches the user's profile
- Creates or links the better-auth user/account, sets the session cookie
- 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
