Native Invite Codes: Gated Account Creation
Account creation on self.surf is controlled by the PDS's native invite code system. Your backend silently mints a single-use code, uses it to create the account, then sets up the profile — the user never sees or types a code.
Architecture
User → your app (backend) → createInviteCode (admin auth) → self.surf PDS
→ createAccount (with invite code) → self.surf PDS
→ putRecord (create profile) → self.surf PDS
Internet → Cloudflare Tunnel → pds:3000 (AT Protocol paths)
→ self-surf.pages.dev (everything else, via catch-all)Backend Flow (Three Steps)
The PDS requires invite codes by default (PDS_INVITE_REQUIRED=true). Your backend silently mints a single-use invite code then immediately uses it. The user never sees or types a code.
const PDS_URL = process.env.PDS_URL; // https://self.surf
const PDS_ADMIN_PASSWORD = process.env.PDS_ADMIN_PASSWORD; // store as a secret
// Step 1: Mint a single-use invite code
const inviteRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`admin:${PDS_ADMIN_PASSWORD}`).toString('base64')}`,
},
body: JSON.stringify({ useCount: 1 }),
});
const { code } = await inviteRes.json(); // e.g. "self-surf-xxxx-xxxx-xxxx"
// Step 2: Create the account using the invite code
const createRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createAccount`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, handle, password, inviteCode: code }),
});
const account = await createRes.json(); // { did, handle, accessJwt, refreshJwt }
// Step 3: Create an empty profile record (prevents "Profile not found" errors)
await fetch(`${PDS_URL}/xrpc/com.atproto.repo.putRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${account.accessJwt}`,
},
body: JSON.stringify({
repo: account.did,
collection: 'app.bsky.actor.profile',
rkey: 'self',
record: { '$type': 'app.bsky.actor.profile' },
}),
});Environment Variables
| Variable | Where | Notes |
|---|---|---|
PDS_ADMIN_PASSWORD | Backend only (Vercel / Cloudflare Pages secret) | Same value as on the server — never expose client-side |
PDS_URL | Backend | https://self.surf |
Verification
# PDS is healthy
curl https://self.surf/xrpc/_health
# → {"version":"0.4.x"}
# Account creation without invite code is blocked by PDS natively
curl -X POST https://self.surf/xrpc/com.atproto.server.createAccount \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","handle":"test.self.surf","password":"testtest"}'
# → {"error":"InvalidInviteCode","message":"No invite code provided"}Note: This uses the official PDS Docker image unmodified — no source code changes, full AT Protocol compatibility and federation.