Implementing Bluesky OAuth Authentication in Nextjs & BetterAuth
Unlike traditional OAuth where you register your app on a central dashboard, Bluesky is a distributed network of servers and there is no central authority to it. This presents new challenges.
How Bluesky OAuth Works
1. Client Registration
-
Unlike traditional OAuth where you register your app on a dashboard, Bluesky uses a self-hosted client metadata file approach.
-
Your app hosts a metadata file at a publicly accessible URL (e.g., /client-metadata.json).
-
The metadata includes your app's name, redirect URIs, and JWKS endpoint.
-
URL of this metadata file will be the
client_id
value.
2. Key Generation
-
Your app needs to generate an ES256 (Elliptic Curve) key pair
-
The private key is used to sign authentication requests
-
The public key is exposed through a JWKS (JSON Web Key Set) endpoint
-
Bluesky uses this to verify your app's identity
3. Authorization Flow
-
User clicks "Connect with Bluesky"
-
Your app initiates the OAuth flow by redirecting to Bluesky's authorization endpoint
-
The request includes the user's handle and a state parameter for security
-
Bluesky verifies your app's metadata and JWKS
Token Exchange
-
After user approval, Bluesky redirects back to your callback URL
-
Your app exchanges the authorization code for access and refresh tokens
-
The token request is signed using your private key
-
Tokens are DPoP-bound for enhanced security
Session Management
-
Store the access token, refresh token, and expiration time
-
Use refresh tokens to maintain long-term access
-
Implement token rotation for security
First Step: Key generation
Go to https://jwkset.com/generate and generate a new key.
Key type: ECDSA
Key algorithm: ES256
Key use: Signature
Keep the JSON Web Key (JWK format) of it. You will need it. It will be something like this:
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "111111-6666-4557-7777-bf9696969696",
"crv": "P-256",
"x": "123123123123",
"y": "456456456456456456456",
"d": "79789789789789797"
}
Implementation
The implementation requires four endpoints:
- /client-metadata.json - Serves your OAuth client metadata
- /jwks.json - Serves your public key in JWKS format
- /api/auth/bluesky - Initiates the OAuth flow
- /api/auth/bluesky/callback - Handles the OAuth callback
client-metadata.json endpoint
This is where you self-host your client metadata file, Bluesky calls this endpoint each time to verify the identity of your app. It's like this:
{
client_id: "https://your-app.com/client-metadata.json",
application_type: "web",
client_name: "your-app-name",
redirect_uris: ["https://your-app.com/api/auth/bluesky/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
scope: "atproto transition:generic",
dpop_bound_access_tokens: true,
token_endpoint_auth_method: "private_key_jwt",
jwks_uri: "https://your-app.com/jwks.json",
token_endpoint_auth_signing_alg: "ES256",
}
replace your-app.com address with your actual app url.
jwks.json endpoint
this endpoint can be a static file, or a dynamic one. it should serve the JWK format you got from step before without the "d"
property of it. "d"
is the private key and YOU SHOULD NOT EXPOSE IT ANYWHERE.
It will be something like this:
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "111111-6666-4557-7777-bf9696969696",
"crv": "P-256",
"x": "123123123123",
"y": "456456456456456456456"
}
Define BlueskyOAuthClient
I decided to use @atproto/oauth-client-node
npm package (it's official from bluesky team) to facilitate this for me.
npm i @atproto/oauth-client-node
This npm package needs to be setup.
export const BlueskyOAuthClient = new NodeOAuthClient({
// same metadata as you exposed in /client-metadata.json endpoint
clientMetadata: {
client_id: "https://your-app.com/client-metadata.json",
application_type: "web",
client_name: "your-app-name",
redirect_uris: ["https://your-app.com/api/auth/bluesky/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
scope: "atproto transition:generic",
dpop_bound_access_tokens: true,
token_endpoint_auth_method: "private_key_jwt",
jwks_uri: "https://your-app.com/jwks.json",
token_endpoint_auth_signing_alg: "ES256",
},
// JWK WITH "d" field
keyset: await Promise.all([
JoseKey.fromImportable(`{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "111111-6666-4557-7777-bf9696969696",
"crv": "P-256",
"x": "123123123123",
"y": "456456456456456456456",
"d": "79789789789789797"
}`),
]),
stateStore: {
async set(key: string, internalState: NodeSavedState): Promise<void> {
const cookieStore = await cookies();
cookieStore.set('oauth_state', JSON.stringify(internalState), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10 minutes
});
},
async get(key: string): Promise<NodeSavedState | undefined> {
const cookieStore = await cookies();
const state = cookieStore.get('oauth_state');
return state ? JSON.parse(state.value) : undefined;
},
async del(key: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete('oauth_state');
},
},
// Interface to store authenticated session data
sessionStore: {
async set(sub: string, session: NodeSavedSession): Promise<void> {
// Insert or Update the session in BetterAuth 'accounts' table
// use sub as AccountId field
},
async get(sub: string): Promise<NodeSavedSession | undefined> {
// Read it back from BetterAuth 'accounts' table.
// search for AccountId=sub
},
async del(sub: string): Promise<void> {
// delete the record from BetterAuth 'accounts' table
// use AccountsId=sub for criteria
},
},
});
auth/bluesky endpoint
This is the endpoint where all the fun starts. User is redirected to this endpoint so that its oauth flow starts. in order to initate an oauth flow, you need to ask for user's bluesky handle. It is needed to find out the server this user is on. (Not all users are on bsky.social server).
Then your endpoint will be like:
export async function GET(request: NextRequest) {
const handle = // get it from request
const state = // from request OR undefined
// Initialize the OAuth flow
const url = await BlueskyOAuthClient.authorize(handle, { state });
return NextResponse.redirect(url);
}
If you've done everything correctly, the user will be redirect to bluesky, and gets authorized there. After that, it is redirected to the "redirect_uri" you set in your metadata file.
auth/bluesky/callback endpoint
This is easy. it looks like this:
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const { session, state } = await BlueskyOAuthClient.callback(searchParams);
console.log('authorize() was called with state:', state);
console.log('User authenticated as:', session.did);
return NextResponse.redirect(request.nextUrl.origin);
}
And Voila!
Now you have Bluesky oauth works perfectly with your Nextjs 15+ and BetterAuth. You can use it like this:
const blueskySession = await BlueskyOAuthClient.restore("bluesky did");
const agent = new Agent(session);
await agent.post(tweetText);
BlueskyOAuthClient takes care of refreshing your token automatically behind the hood.
Notes
- Remember that the
kid
(Key ID) values must match between your private keys (the one you set in NodeOAuthClient setup) and the public JWKS file (jwks.json file). - Make sure your keys are in the correct ES256 format
- Always follow best security practices and store your key in safe places. The code here is only for demonstration and should not be used for production.
- For local development you can use ngrok to give you a public URL you can use.
- Keep your private keys (
"d"
field in JWK) secure and never commit them to version control.