TLDR
Selfsurf is a free service for devs to offer secure, seamless, and easy account signup and login, for apps built with DIDs; account identifiers that are strongly-consistent, recoverable, and allows for key rotation (see web.plc.directory). See our API Client Terms.
How it works
Selfsurf handles account creation and login through ePDS (extended Personal Data Server, source), a passwordless layer over an AT Protocol PDS. Apps integrate via a server-side API key and pick a sign-in method: passwordless email OTP, Bluesky or Mastodon OAuth, or direct server-to-server community DID creation. There are no passwords and no invite codes to manage (the PDS keeps invite codes enabled to prevent programmatic spam, but your frontend never asks the user for one). Full AT Protocol compatibility and federation are preserved.
Getting an API Key
Contact the selfsurf operator to register your app. Or self host, see source.
Auth
Selfsurf supports two kinds of sign-in: email (passwordless OTP, which creates a single-purpose account) and OAuth with an existing identity (Mastodon or Bluesky, for multi-purpose accounts reused across apps). And server-to-server account creation for Community DIDs (work in progress).
Email (OTP)
Email sign-in is auth, not OAuth — a passwordless one-time passcode, with no third-party authorization server or redirect. Under the hood it's powered by Better Auth's emailOTP plugin (which owns code generation, expiry, and verification); ePDS then bridges the verified email to a real AT Protocol session.
Architecture
User → your app → your backend → /_internal/otp/send → self.surf ePDS (sends email)
→ /_internal/otp/verify → self.surf ePDS (returns session)
Internet → Cloudflare Tunnel → ePDS auth-service (OTP + login)
→ ePDS pds-core (AT Protocol) Backend Flow (Two Steps)
Your backend sends a code with /_internal/otp/send, then verifies the code the user entered with /_internal/otp/verify, which returns an AT Protocol session.
const AUTH_URL = process.env.EPDS_AUTH_URL; // https://auth.self.surf
const API_KEY = process.env.EPDS_API_KEY; // store as a secret
// Step 1: Send OTP code to user's email
await fetch(`${AUTH_URL}/_internal/otp/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
body: JSON.stringify({
email: 'alice@example.com',
purpose: 'signup', // or 'login'
}),
});
// Step 2: Verify the code the user entered
const res = await fetch(`${AUTH_URL}/_internal/otp/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
body: JSON.stringify({
email: 'alice@example.com',
otp: '12345678', // code from email
purpose: 'signup', // or 'login'
handle: 'alice', // required for signup only
}),
});
const session = await res.json();
// { did, handle, accessJwt, refreshJwt, created: true } Bluesky (OAuth)
Bluesky sign-in is OAuth, and not a selfsurf-specific endpoint — it's standard AT Protocol OAuth, which nearly every PDS operator (including selfsurf) supports. Your app acts as an atproto OAuth client and the user signs in with their existing handle/DID at their own PDS. No x-api-key is involved. See the official guide: atproto.com/guides/about-oauth.
import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
// Standard AT Protocol OAuth — no self.surf-specific endpoint or API key.
// Your app is the OAuth client; the user authorizes at their own PDS.
const client = await BrowserOAuthClient.load({
clientId: 'https://yourapp.com/client-metadata.json',
handleResolver: 'https://bsky.social',
});
// Step 1: kick off sign-in with the user's handle or DID.
await client.signIn('alice.bsky.social', {
// redirects the browser to the user's PDS authorize page
});
// Step 2: on return to your redirect URI, finish the flow.
const result = await client.init();
if (result?.session) {
const { sub: did } = result.session; // authenticated DID
// use result.session to make authenticated atproto requests
} Mastodon (OAuth)
“Sign in with Mastodon” works against any Mastodon instance and is fully headless — ePDS discovers the user's instance, dynamically registers itself there (PKCE), and manages all OAuth state server-side. Your app only redirects the browser and posts the returned code back. All three endpoints use the same x-api-key as the OTP flow.
const AUTH_URL = process.env.EPDS_AUTH_URL; // https://auth.self.surf
const API_KEY = process.env.EPDS_API_KEY; // store as a secret
// Step 1: Start the flow. ePDS discovers the user's Mastodon instance
// (any instance), registers itself there, and returns an authorize URL.
const start = await fetch(`${AUTH_URL}/_internal/mastodon/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
body: JSON.stringify({
handle: 'alice@mastodon.social', // user's @handle@instance
redirectUri: 'https://yourapp.com/auth/mastodon/callback',
// claimHandle: 'alice', // optional: force a handle (AAA path)
}),
});
const { authorizeUrl, state } = await start.json();
// → redirect the user's browser to authorizeUrl
// Step 2: Mastodon redirects back to your redirectUri with ?code=...
// Post the code back to ePDS to exchange it.
const cb = await fetch(`${AUTH_URL}/_internal/mastodon/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
body: JSON.stringify({ code, state }),
});
const result = await cb.json();
// Returning user (or claimHandle was set): you get a session immediately.
// { did, handle, accessJwt, refreshJwt, created?, mastodonProfile? }
// New user with no forced handle: you get a verifiedToken instead.
// { needsHandle: true, provider: 'mastodon', providerAccount, suggestedHandle, verifiedToken }
// Collect a handle from the user, then:
if (result.needsHandle) {
const done = await fetch(`${AUTH_URL}/_internal/mastodon/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
body: JSON.stringify({ verifiedToken: result.verifiedToken, handle: 'alice' }),
});
const session = await done.json();
// { did, handle, accessJwt, refreshJwt, created: true, mastodonProfile }
} Endpoints (base https://auth.self.surf):
POST /_internal/mastodon/start—{ handle, redirectUri, claimHandle? }→{ authorizeUrl, state }POST /_internal/mastodon/callback—{ code, state }→ a session, or{ needsHandle, verifiedToken, suggestedHandle }for new usersPOST /_internal/mastodon/complete—{ verifiedToken, handle }→ a session
Profile import
On signup, ePDS captures the user's Mastodon profile from verify_credentials and returns it as mastodonProfile alongside the session, so you can pre-fill the new account's profile. The bio is raw HTML — strip it before storing — and the avatar prefers the non-animated avatar_static.
// Returned alongside the session on signup, captured from the user's
// Mastodon verify_credentials. Import it into the user's profile record.
{
"mastodonProfile": {
"displayName": "Alice",
"bio": "<p>just here for the cat pics</p>", // HTML; strip before storing
"avatarUrl": "https://mastodon.social/.../avatar.png" // prefers avatar_static
}
}DIDs
Every account on selfsurf is an AT Protocol DID — portable, recoverable, with key rotation. Beyond the standard per-user account, selfsurf supports a few provisioning patterns.
Anonymous DIDs
Single purpose apps can back up a user's data using email OTP and use a randomly generated string as their handle, enabling the user to receive a recoverable DID.
AAA DIDs
Apps that treat the Bluesky and Mastodon servers as first-class citizens of the open social web can use @attps/aaa (source) as a library to gate the claiming of a handle on selfsurf ePDS. Whenever an existing handle is detected the user can OAuth into selfsurf to claim the respective handle / use their primary PDS DID.
Community DIDs
Community DIDs are accounts that represent a group or community rather than a single human. Provisioned server-to-server with no email-OTP round-trip (there's no inbox to receive a code). This requires a dedicated can_create_directly permission on your API key (off by default, granted by the operator).
const AUTH_URL = process.env.EPDS_AUTH_URL; // https://auth.self.surf
const API_KEY = process.env.EPDS_API_KEY; // must have can_create_directly
// Create an account server-to-server with no email-OTP round-trip.
const res = await fetch(`${AUTH_URL}/_internal/account/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
body: JSON.stringify({
handle: 'mycommunity', // 5–20 chars, lowercase letters/numbers/hyphens, no dots, no leading/trailing hyphen
email: 'mycommunity+abc@yourapp.internal', // opaque, no mail is sent
}),
});
const session = await res.json();
// { did, handle, accessJwt, refreshJwt, created: true } POST /_internal/account/create — { handle, email } → { did, handle, accessJwt, refreshJwt, created: true }. The handle is the local part (5–20 chars, lowercase letters, numbers, or hyphens; no dots; cannot start or end with a hyphen); the email is opaque and never receives mail.
Environment Variables
Email (OTP), Mastodon (OAuth), and all three DID patterns (anonymous, AAA, community) talk to ePDS and share the same two backend variables. Bluesky (OAuth) is the exception — it's standard AT Protocol OAuth that never touches ePDS, so it uses client-side config (a published client metadata URL and a handle resolver) instead of the ePDS key/URL.
Backend
Server-side only, for OTP, Mastodon, and the DID patterns. Never expose these client-side.
| Variable | Used by | Notes |
|---|---|---|
EPDS_API_KEY | OTP, Mastodon, DIDs | Never expose client-side. Community DIDs need the can_create_directly permission on this key. |
EPDS_AUTH_URL | OTP, Mastodon, DIDs | https://auth.self.surf |
Client
Bluesky (OAuth) only — standard atproto OAuth config, with no x-api-key.
| Variable | Used by | Notes |
|---|---|---|
clientId | Bluesky only | URL of your published client-metadata.json. |
handleResolver | Bluesky only | Resolver endpoint, e.g. https://bsky.social. |
Verification
The health check applies to every integration; the rest are per-method smoke tests. Community DIDs have no read-only check — the only way to confirm can_create_directly is to call /_internal/account/create.
# PDS is healthy (all methods)
curl https://self.surf/xrpc/_health
# → {"version":"0.4.x"}
# Email (OTP): send a test code (returns success even for non-existent accounts)
curl -X POST https://auth.self.surf/_internal/otp/send \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{"email":"test@test.com","purpose":"login"}'
# → {"success":true}
# Mastodon (OAuth): start a flow — checks your API key + instance discovery
# without any real Mastodon round-trip. Returns an authorize URL.
curl -X POST https://auth.self.surf/_internal/mastodon/start \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{"handle":"test@mastodon.social","redirectUri":"https://yourapp.com/cb"}'
# → {"authorizeUrl":"https://mastodon.social/oauth/authorize?...","state":"..."}
# Bluesky (OAuth): no ePDS endpoint — instead confirm your client metadata
# is published and reachable (this is what atproto PDSes fetch).
curl https://yourapp.com/client-metadata.json
# → {"client_id":"https://yourapp.com/client-metadata.json", ...}