Setting Up Bluesky OAuth in Your Web App: A Developer's Guide
If you're building an app that needs to authenticate with Bluesky, you'll need to implement their OAuth flow. While the official docs are comprehensive, they can be a bit overwhelming. Let's break down the process into manageable steps.
Couple of thing I need to say before we get into the actual code, I am using @atproto/oauth-client-node
however there is also another browser specific package called @atproto/oauth-client-browser
which I will not be covering in this guide cause I haven't used it and it seems to be mostly for web apps that don't have a backend server (e.g. single page applications).
So far I have'nt found differences in terms of the functionality these packages provide beside the @atproto/oauth-client-node
being better for fullstack applications as you can manage more data on the backend without worrying of exposing something sensitive to the client.
What You'll Need
- A Next.js app (or similar web framework)
- ngrok or your preffered tunneling tool (for development)
@atproto/oauth-client-node
package
Step 1: Setting Up Your Development Environment
First, we need to set up ngrok to expose our local development server. This is necessary because Bluesky's OAuth requires HTTPS URLs, even during development.
# Install ngrok globally if you haven't already
npm install -g ngrok
# Start your Next.js app
npm run dev
# In another terminal, start ngrok
ngrok http 3000
Take note of your ngrok URL (e.g., https://your-tunnel.ngrok.io
). We'll use this throughout our implementation.
Step 2: Generating Authentication Keys
Bluesky uses ES256 keys for authentication. Here's how to generate them:
Go to this website https://jwkset.com/generate and generate your JWK set.
Copy paste the key set into your .env.local
file
Step 3: Setting Up the OAuth Client
Here's the core configuration for your Bluesky OAuth client:
// lib/bluesky-client.ts
import { NodeOAuthClient } from "@atproto/oauth-client-node";
import { JoseKey } from "@atproto/jwk-jose";
let clientInstance: NodeOAuthClient | null = null;
const stateStore = new Map();
const sessionStore = new Map();
export async function getBlueskyClient() {
if (clientInstance) return clientInstance;
// Replace with your ngrok URL
const BASE_URL = "https://your-tunnel.ngrok.io";
clientInstance = new NodeOAuthClient({
clientMetadata: {
client_id: `${BASE_URL}/api/oauth/client-metadata.json`,
client_name: "Your App Name",
client_uri: BASE_URL,
redirect_uris: [`${BASE_URL}/auth/callback`],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "private_key_jwt",
token_endpoint_auth_signing_alg: "ES256",
scope: "atproto",
application_type: "web",
dpop_bound_access_tokens: true,
jwks_uri: `${BASE_URL}/api/oauth/jwks.json`
},
keyset: await Promise.all([
JoseKey.fromImportable(process.env.PRIVATE_KEY_1),
JoseKey.fromImportable(process.env.PRIVATE_KEY_2),
JoseKey.fromImportable(process.env.PRIVATE_KEY_3),
]),
stateStore: {
async set(key: string, state: any) {
stateStore.set(key, state);
},
async get(key: string) {
return stateStore.get(key);
},
async del(key: string) {
stateStore.delete(key);
}
},
sessionStore: {
async set(sub: string, session: any) {
sessionStore.set(sub, session);
},
async get(sub: string) {
return sessionStore.get(sub);
},
async del(sub: string) {
sessionStore.delete(sub);
}
}
});
return clientInstance;
}
Step 3.1: Required JSON Files
In your Next.js application, you'll need two important JSON files that Bluesky's servers will access to verify your application. These files need to be placed in the public
directory of your Next.js app so they're accessible via direct URLs:
Client Metadata File (public/client-metadata.json
)
This file describes your application to Bluesky's authentication servers. Here's an example of how it should look (replace with your own values):
{
"client_id": "https://example-app.ngrok-free.app/client-metadata.json",
"client_name": "Example App",
"client_uri": "https://example-app.ngrok-free.app",
"application_type": "web",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"scope": "atproto",
"redirect_uris": [
"https://example-app.ngrok-free.app/auth/callback"
],
"dpop_bound_access_tokens": true,
"jwks_uri": "https://example-app.ngrok-free.app/jwks.json"
}
This file will be accessible at https://your-domain/client-metadata.json
(during development, this will be your ngrok URL).
JWKS File (public/jwks.json
)
This file contains the public parts of your ES256 keys. It's used by Bluesky to verify your application's identity. Here's an example:
{
"keys": [
{
"kty": "EC",
"use": "sig",
"kid": "key1",
"crv": "P-256",
"x": "Example-x-value-base64url-encoded",
"y": "Example-y-value-base64url-encoded"
},
{
"kty": "EC",
"use": "sig",
"kid": "key2",
"crv": "P-256",
"x": "Another-example-x-value",
"y": "Another-example-y-value"
},
{
"kty": "EC",
"use": "sig",
"kid": "key3",
"crv": "P-256",
"x": "Third-example-x-value",
"y": "Third-example-y-value"
}
]
}
This file will be accessible at https://your-domain/jwks.json
.
Environment Variables (.env.local
)
Your private keys should be stored securely in your environment variables. Here's an example of how your .env.local
file should look:
PRIVATE_KEY_1='{
"kty": "EC",
"key_ops": ["sign"],
"kid": "key1",
"crv": "P-256",
"x": "Example-x-value-base64url-encoded",
"y": "Example-y-value-base64url-encoded",
"d": "Private-key-value-keep-this-secret"
}'
PRIVATE_KEY_2='{
"kty": "EC",
"key_ops": ["sign"],
"kid": "key2",
"crv": "P-256",
"x": "Another-example-x-value",
"y": "Another-example-y-value",
"d": "Another-private-key-value"
}'
PRIVATE_KEY_3='{
"kty": "EC",
"key_ops": ["sign"],
"kid": "key3",
"crv": "P-256",
"x": "Third-example-x-value",
"y": "Third-example-y-value",
"d": "Third-private-key-value"
}'
Note: The values shown above are examples - you should never share your actual private keys ("d" values). Remember that the kid
(Key ID) values must match between your private keys and the public JWKS file.
File Structure
Your Next.js project structure should look something like this:
your-project/
├── public/
│ ├── client-metadata.json
│ └── jwks.json
├── app/
│ └── [rest of your app files]
└── .env.local
Step 4: Creating the Required API Routes
You'll need several API routes to handle the OAuth flow:
// app/api/auth/signin/route.ts
export async function POST(request: NextRequest) {
const { handle } = await request.json();
const client = await getBlueskyClient();
const state = crypto.randomUUID();
const url = await client.authorize(handle, { state });
return NextResponse.json({ url });
}
// app/api/auth/callback/route.ts
export async function GET(request: NextRequest) {
const client = await getBlueskyClient();
const params = request.nextUrl.searchParams;
const result = await client.callback(params);
if (!result?.session) {
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 400 }
);
}
const response = NextResponse.json({ success: true });
response.cookies.set({
name: "bluesky_session",
value: JSON.stringify(result.session),
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/"
});
return response;
}
Step 5: Creating the Login Component
Here's a simple login component:
export default function BlueskyLogin() {
const [handle, setHandle] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/auth/signin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ handle })
});
const data = await response.json();
if (data.url) {
window.location.href = data.url;
}
} catch (err) {
setError("Failed to initialize login");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="Your Bluesky handle"
/>
<button type="submit" disabled={loading}>
{loading ? "Connecting..." : "Sign in with Bluesky"}
</button>
{error && <div>{error}</div>}
</form>
);
}
Important Notes
- Always use your ngrok URL during development
- Make sure all your redirect URIs match exactly
- Store session data securely (the example uses in-memory storage which isn't suitable for production)
- Keep your private keys secure and never commit them to version control
For Production
When deploying to production, you'll need to:
- Replace ngrok URLs with your actual domain
- Implement proper session storage (e.g., database)
- Set up secure key management
- Enable proper HTTPS
- Implement token refresh handling
Common Issues
- If you get key-related errors, make sure your keys are in the correct ES256 format
- If redirects fail, verify all URLs match exactly between your configuration and Bluesky's expectations
- For callback errors, check that your callback route is properly handling the OAuth parameters
Remember, this is a basic implementation. For production use, you'll want to add error handling, token refresh logic, and proper session management.
I hope this helps you get started with Bluesky OAuth! Feel free to reach out if you have questions or run into issues.