A Wasp starter with "Log in with Bluesky" wired up as a custom auth provider. Users sign in with their AT Protocol handle, and Wasp gets a real session with a User row keyed on the account's DID.
Built by following Wasp's Custom OAuth Provider guide, with the OAuth half swapped out for AT Protocol. See below for why.
Note
New to Wasp? Wasp is a full-stack React + Node.js + Prisma framework where you describe your app (routes, pages, auth, APIs, jobs) in a single main.wasp.ts file and Wasp generates the glue: the client, the server, the database client, and end to end types. The Introduction is a 5 minute read, and the Tutorial builds a full app in about 20 minutes.
The AT Protocol part of this repo is roughly 300 lines in src/bluesky/. Everything around it is Wasp:
| You would otherwise write | Wasp gives you | Where it shows up |
|---|---|---|
Session cookies, token issuing, useAuth plumbing |
Auth | auth: {} in main.wasp.ts, useAuth() in src/MainPage.tsx |
| An Express app, route wiring, typed handlers | Custom HTTP API endpoints | src/bluesky/spec.wasp.ts |
| A Prisma setup and a dev database | Entities and Databases | schema.prisma, wasp db start |
| A React router and page/layout wiring | Pages and routes | route("RootRoute", "/", page(MainPage)) |
| Env var loading, validation, and client/server split | Env variables | .env.server, .env.client |
| A deploy pipeline for two services plus a database | wasp deploy |
see Deploying |
Tip
Wasp is not a hosted platform or a lock-in. It generates a normal Node.js server and a normal Vite client into .wasp/out/, which you can read at any time.
Bluesky uses the AT Protocol OAuth profile, not plain OAuth 2.0. Four things differ from a typical provider:
- No client secret, and nothing to register. Your
client_idis a URL that serves a public client metadata document. Authorization servers fetch it. - PAR and DPoP are mandatory. Authorization requests are pushed server to server, and tokens are bound to a per session key.
- The authorization server is per account. A handle resolves to a DID, the DID document points at the user's PDS, and the PDS points at its authorization server. So the flow needs the user's handle up front, and accounts on any PDS work, not just
bsky.social. - Localhost is special. Development uses a spec-defined loopback exception instead of a hosted metadata document.
The custom OAuth guide uses Arctic, which has no Bluesky provider and cannot express any of the above. This starter uses @atproto/oauth-client-node instead. Everything else from the guide carries over unchanged: the api() routes, findAuthIdentity / createUser / tokenStore / getRedirectUriForOneTimeCode, and the switch to 127.0.0.1.
Note
If you want a provider Wasp already supports, you do not need any of this. Social auth covers Google, GitHub, Keycloak, Slack, Discord, and Microsoft in a few lines of config, with prebuilt login UI included.
You need Wasp installed and Docker running (Wasp uses it for the dev database).
npm install
cp .env.server.example .env.server
cp .env.client.example .env.client
wasp db start # starts a dev Postgres in Docker, keep this running
wasp db migrate-dev # in a second terminal
wasp startThen open http://127.0.0.1:3000 and enter a Bluesky handle.
Important
Use 127.0.0.1, not localhost. The AT Protocol loopback exception only accepts http://127.0.0.1 and http://[::1] as redirect URIs. That is why .env.server overrides WASP_SERVER_URL and WASP_WEB_CLIENT_URL away from Wasp's localhost defaults. The port number does not matter, it is ignored when matching loopback redirect URIs, so set PORT too if 3001 is taken. See Env variables for the full list of what Wasp reads.
No developer dashboard, API key, or app registration is needed for development.
Tip
wasp compile type checks the whole app, spec included. Use it instead of running tsc directly. Full command list in the CLI reference.
| File | What it does |
|---|---|
main.wasp.ts |
The app spec: auth config, the root route, and the Bluesky routes spliced in |
src/bluesky/spec.wasp.ts |
The four api() routes, exported as a Spec fragment |
src/bluesky/client.ts |
Builds the OAuth client metadata and a per request NodeOAuthClient |
src/bluesky/auth.ts |
The route handlers, plus mapping a Bluesky account onto a Wasp user |
src/bluesky/profile.ts |
Reads handle, display name, and avatar from the public AppView |
src/MainPage.tsx |
Handle input, and the logged in profile view via useAuth() |
The flow:
GET /auth/bluesky?handle=alice.bsky.socialresolves the handle to a PDS, pushes the authorization request, and redirects to that PDS's consent screen.- The user approves, and the PDS redirects back to
GET /auth/bluesky/callback. - The handler exchanges the code, reads the account's DID, finds or creates the Wasp user, and redirects to Wasp's built-in
/oauth/callbackpage with a one time code. - That page exchanges the code for a Wasp session. The user is logged in.
OAuth state lives in an httpOnly cookie, with the same options Wasp uses for its own providers (see cookies.ts in Wasp's OAuth internals). That is why the client is constructed per request rather than once at module scope: the state store closes over req and res. No database table is needed for in-flight logins.
The AT Protocol session is not persisted. The atproto scope is enough to log someone in, and the DID is read immediately in the callback, so the session store is a throwaway Map. See "Calling the API as the user" below to change this.
Add columns to User in schema.prisma, run wasp db migrate-dev, then extend the object passed to createUser in src/bluesky/auth.ts. The raw profile is already serialized into AuthIdentity.providerData, so nothing is lost if you add a field later. Accessing user data explains what Wasp puts on the User entity and how to read it on both client and server.
This starter only authenticates. To also act on someone's behalf:
- Widen
SCOPEinsrc/bluesky/client.tsto"atproto transition:generic". The loopbackclient_idis built fromSCOPE, so it updates automatically. - Replace
createInMemorySessionStore()with a store backed by a Prisma model keyed on the DID. The value is JSON, so a singleStringcolumn is enough. - Install
@atproto/apias a regular npm dependency, thenclient.restore(did)and pass the result tonew Agent(session). Token refresh is handled for you.
PROVIDER_NAME in src/bluesky/auth.ts is what lands in AuthIdentity.providerName. Change it before you have real users, not after.
Now that login works, the rest of an app is mostly config:
- Read and write data. Queries and actions are plain server functions that Wasp exposes to React with types, caching, and invalidation. Automatic CRUD generates the common ones for you.
- Run something on a schedule. Backfill profiles or poll a firehose with recurring jobs.
- Push updates live. Web sockets for feeds and notifications.
- Send email. Sending emails with a provider of your choice.
- Gate or extend the login. Auth hooks run before redirect, after sign up, and before session creation. Useful for allowlists or invite codes.
- Make it look like yours. Tailwind, shadcn, or Radix Themes.
- Start from something bigger. Starter templates, including OpenSaaS if you want billing and admin out of the box.
Once your server is reachable over HTTPS, the loopback exception no longer applies and the app becomes a confidential client that signs its token requests. src/bluesky/client.ts switches automatically based on whether WASP_SERVER_URL is https:. You need to:
-
Generate an ES256 private key and set it as
BLUESKY_PRIVATE_KEYin the server env (one line):node --input-type=module -e "import {generateKeyPair,exportJWK} from 'jose'; const {privateKey} = await generateKeyPair('ES256', {extractable: true}); console.log(JSON.stringify({...await exportJWK(privateKey), alg: 'ES256'}))" -
Confirm
GET /auth/bluesky/client-metadata.jsonandGET /auth/bluesky/jwks.jsonare publicly reachable. Authorization servers fetch both.
If you add branding to the metadata, note that client_uri must share a hostname with client_id, which is served by the server. Pointing it at your client URL fails the authorization request with invalid_client_metadata unless both live on the same host. Authorization servers hide client_uri, client_name, and logo_uri for untrusted clients anyway, so client_uri is omitted here.
Tip
wasp deploy fly launch bsky-login mia provisions the client, the server, and a Postgres database in one command. See Fly.io or Railway, and Deployment overview for everything else, including self-hosting.
Warning
If you run more than one server instance and persist AT Protocol sessions, replace requestLocalLock in src/bluesky/client.ts with a distributed lock. It is process-local, and concurrent token refreshes across instances can get credentials revoked.
Caution
zod is pinned to v4 in package.json. The @atproto/* packages depend on zod 3, which npm hoists to the root and shadows the zod 4 that Wasp's SDK bundles against, crashing the server with z.url is not a function. Pinning v4 at the root pushes zod 3 down into nested node_modules where the atproto packages still find it. Keep this line if you add more atproto dependencies.
Google is enabled with dummy credentials, on purpose. Wasp only exports the OAuth helpers these handlers rely on (tokenStore, getRedirectUriForOneTimeCode) when at least one built-in OAuth provider is configured. The Google route exists but is inert. To hide it entirely, reject it from an onBeforeOAuthRedirect hook.
Every Bluesky route sets auth: false. Wasp defaults api() routes to auth: true once the app has auth enabled, which is wrong for login endpoints.
tokenStore and getRedirectUriForOneTimeCode are private Wasp APIs. The custom OAuth guide uses them for the same reason: there is currently no public API for a fully custom OAuth flow. They may change between Wasp versions. Verified against Wasp 0.25.
The whole server half can be checked without credentials:
# Should show the loopback client_id and your redirect URI
curl -s http://127.0.0.1:3001/auth/bluesky/client-metadata.json
# Should 302 to bsky.social with a request_uri, proving PAR succeeded
curl -si "http://127.0.0.1:3001/auth/bluesky?handle=bsky.app" | grep -i location- Wasp: docs · Discord · GitHub
- AT Protocol: OAuth spec · atproto docs