ePDS lets users log in with their email address. There are no passwords — the user receives a one-time code by email and enters it to authenticate. Your app sends the user to ePDS, the user authenticates there, and ePDS sends them back to your app with a token you can use to make API calls on their behalf.
There are two ways to integrate:
Flow 1 — your app has its own email input. You pass the email to ePDS and the user lands directly on the code-entry screen.
Flow 2 — your app has a simple "Sign in" button. ePDS shows the email form itself.
Both flows end the same way: the user enters their code, ePDS redirects back to your app, and your app exchanges that redirect for a token.
- User enters email in your app and clicks "Sign in"
- Your login handler registers the login attempt with ePDS (passing the email)
- Your app redirects the user's browser to the ePDS auth page (with the email)
- ePDS immediately sends the OTP and shows the code-entry screen
- User reads the 8-digit code from their email and submits it
- ePDS verifies the code
- New users only: ePDS shows a handle picker — user chooses their handle
- ePDS redirects back to your app's callback URL
- Your callback handler exchanges the redirect for an access token
- User is logged in
- User clicks "Sign in" in your app
- Your login handler registers the login attempt with ePDS
- Your app redirects the user's browser to the ePDS auth page
- ePDS shows an email input form
- User enters their email and submits
- ePDS sends the OTP and shows the code-entry screen
- User reads the 8-digit code from their email and submits it
- ePDS verifies the code
- New users only: ePDS shows a handle picker — user chooses their handle
- ePDS redirects back to your app's callback URL
- Your callback handler exchanges the redirect for an access token
- User is logged in
sequenceDiagram
actor User
participant App as Client App
participant PDS as PDS Core
participant Auth as Auth Service
participant Email as User's Inbox
User->>App: Enters email, clicks Login
App->>App: Generates DPoP key pair, PKCE verifier
App->>PDS: POST /oauth/par<br/>(client_id, redirect_uri, login_hint=email, DPoP proof)
PDS-->>App: { request_uri }
App->>App: Stores state in signed session cookie
App-->>User: 302 redirect to /oauth/authorize<br/>?request_uri=...&login_hint=email
User->>Auth: GET /oauth/authorize?request_uri=...&login_hint=email
Auth->>Auth: Creates auth_flow row (flow_id, request_uri)<br/>Sets epds_auth_flow cookie
Auth->>Auth: Sends OTP to email (via better-auth, server-side JS call)
Auth-->>User: Renders page with OTP input visible<br/>(email step hidden)
Auth->>Email: Sends 8-digit OTP code
User->>Email: Reads OTP code
User->>Auth: Submits OTP code
Auth->>Auth: Verifies OTP via better-auth<br/>Creates better-auth session
Auth-->>User: 302 redirect to /auth/complete
User->>Auth: GET /auth/complete
Auth->>Auth: Reads epds_auth_flow cookie → flow_id → request_uri<br/>Gets email from better-auth session
alt New user (no PDS account)
Auth-->>User: 302 redirect to /auth/choose-handle
Auth->>PDS: GET /_internal/ping-request (resets PAR inactivity timer)
User->>Auth: Picks handle, POST /auth/choose-handle
Auth->>PDS: GET /_internal/check-handle (availability check)
Auth->>PDS: GET /oauth/epds-callback<br/>?request_uri=...&email=...&approved=1&new_account=1&handle=...&ts=...&sig=HMAC
PDS->>PDS: Verifies HMAC signature<br/>Creates PDS account with chosen handle<br/>Issues authorization code
else Existing user
Auth->>PDS: GET /oauth/epds-callback<br/>?request_uri=...&email=...&approved=1&new_account=0&ts=...&sig=HMAC
PDS->>PDS: Verifies HMAC signature<br/>Issues authorization code
end
PDS-->>User: 302 redirect to client redirect_uri?code=...&state=...
User->>App: GET /api/oauth/callback?code=...&state=...
App->>PDS: POST /oauth/token<br/>(code, code_verifier, DPoP proof)
PDS-->>App: { access_token, refresh_token, sub (DID) }
App->>App: Creates user session
App-->>User: Logged in
sequenceDiagram
actor User
participant App as Client App
participant PDS as PDS Core
participant Auth as Auth Service
participant Email as User's Inbox
User->>App: Clicks Login
App->>App: Generates DPoP key pair, PKCE verifier
App->>PDS: POST /oauth/par<br/>(client_id, redirect_uri, no login_hint, DPoP proof)
PDS-->>App: { request_uri }
App->>App: Stores state in signed session cookie
App-->>User: 302 redirect to /oauth/authorize?request_uri=...
User->>Auth: GET /oauth/authorize?request_uri=...
Auth->>Auth: Creates auth_flow row (flow_id, request_uri)<br/>Sets epds_auth_flow cookie
Auth-->>User: Renders page with email input form visible
User->>Auth: Submits email address
Auth->>Auth: Sends OTP to email (via better-auth JS call)
Auth-->>User: Shows OTP input
Auth->>Email: Sends 8-digit OTP code
User->>Email: Reads OTP code
User->>Auth: Submits OTP code
Auth->>Auth: Verifies OTP via better-auth<br/>Creates better-auth session
Auth-->>User: 302 redirect to /auth/complete
User->>Auth: GET /auth/complete
Auth->>Auth: Reads epds_auth_flow cookie → flow_id → request_uri<br/>Gets email from better-auth session
alt New user (no PDS account)
Auth-->>User: 302 redirect to /auth/choose-handle
Auth->>PDS: GET /_internal/ping-request (resets PAR inactivity timer)
User->>Auth: Picks handle, POST /auth/choose-handle
Auth->>PDS: GET /_internal/check-handle (availability check)
Auth->>PDS: GET /oauth/epds-callback<br/>?request_uri=...&email=...&approved=1&new_account=1&handle=...&ts=...&sig=HMAC
PDS->>PDS: Verifies HMAC signature<br/>Creates PDS account with chosen handle<br/>Issues authorization code
else Existing user
Auth->>PDS: GET /oauth/epds-callback<br/>?request_uri=...&email=...&approved=1&new_account=0&ts=...&sig=HMAC
PDS->>PDS: Verifies HMAC signature<br/>Issues authorization code
end
PDS-->>User: 302 redirect to client redirect_uri?code=...&state=...
User->>App: GET /api/oauth/callback?code=...&state=...
App->>PDS: POST /oauth/token<br/>(code, code_verifier, DPoP proof)
PDS-->>App: { access_token, refresh_token, sub (DID) }
App->>App: Creates user session
App-->>User: Logged in
Before users can log in, you need to tell ePDS about your app. You do this by hosting a small JSON file at a public HTTPS URL — this URL also acts as your app's identifier. ePDS fetches this file to verify your app is legitimate and to find your callback URL.
The file must be served with Content-Type: application/json:
{
"client_id": "https://yourapp.example.com/client-metadata.json",
"client_name": "Your App Name",
"client_uri": "https://yourapp.example.com",
"logo_uri": "https://yourapp.example.com/logo.png",
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
}The last four fields are fixed values required by the AT Protocol — copy them
as-is. The only things you need to change are client_id, client_name,
client_uri, logo_uri, and redirect_uris.
You can customise the OTP email and login page colours:
{
"email_template_uri": "https://yourapp.example.com/email-template.html",
"email_subject_template": "{{code}} — Your {{app_name}} code",
"brand_color": "#000000",
"background_color": "#ffffff"
}The email template must be an HTML file containing at minimum a {{code}}
placeholder. Supported template variables:
| Variable | Description |
|---|---|
{{code}} |
The 8-digit OTP code (required) |
{{app_name}} |
Value of client_name from your metadata |
{{logo_uri}} |
Value of logo_uri from your metadata |
{{#is_new_user}}...{{/is_new_user}} |
Shown only on first sign-up |
{{^is_new_user}}...{{/is_new_user}} |
Shown only on subsequent sign-ins |
ePDS uses two standard security mechanisms to protect the login flow:
- PKCE — prevents an attacker who intercepts the final redirect from using the code themselves
- DPoP — binds the access token to your server so it can't be used by anyone who steals it
You don't need to understand the details — just copy these helper functions (from packages/demo) and call them as shown in the code examples below:
import * as crypto from 'node:crypto'
// PKCE
export function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url')
}
export function generateCodeChallenge(verifier: string): string {
return crypto.createHash('sha256').update(verifier).digest('base64url')
}
// DPoP key pair — generate once per OAuth flow, never reuse across flows
export function generateDpopKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
})
return {
privateKey,
publicJwk: publicKey.export({ format: 'jwk' }),
privateJwk: privateKey.export({ format: 'jwk' }),
}
}
// Restore a DPoP key pair from a serialized private JWK (e.g. from session)
export function restoreDpopKeyPair(privateJwk: crypto.JsonWebKey) {
const privateKey = crypto.createPrivateKey({ key: privateJwk, format: 'jwk' })
const publicKey = crypto.createPublicKey(privateKey)
return { privateKey, publicJwk: publicKey.export({ format: 'jwk' }) }
}
// Create a DPoP proof JWT
export function createDpopProof(opts: {
privateKey: crypto.KeyObject
jwk: object
method: string
url: string
nonce?: string
accessToken?: string
}): string {
const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: opts.jwk }
const payload: Record<string, unknown> = {
jti: crypto.randomUUID(),
htm: opts.method,
htu: opts.url,
iat: Math.floor(Date.now() / 1000),
}
if (opts.nonce) payload.nonce = opts.nonce
if (opts.accessToken) {
payload.ath = crypto
.createHash('sha256')
.update(opts.accessToken)
.digest('base64url')
}
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url')
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
const signingInput = `${headerB64}.${payloadB64}`
const sig = crypto.sign('sha256', Buffer.from(signingInput), opts.privateKey)
return `${signingInput}.${derToRaw(sig).toString('base64url')}`
}
// Convert DER-encoded ECDSA signature to raw r||s (required for ES256 JWTs)
function derToRaw(der: Buffer): Buffer {
let offset = 2
if (der[1]! > 0x80) offset += der[1]! - 0x80
offset++ // skip 0x02
const rLen = der[offset++]!
let r = der.subarray(offset, offset + rLen)
offset += rLen
offset++ // skip 0x02
const sLen = der[offset++]!
let s = der.subarray(offset, offset + sLen)
if (r.length > 32) r = r.subarray(r.length - 32)
if (s.length > 32) s = s.subarray(s.length - 32)
const raw = Buffer.alloc(64)
r.copy(raw, 32 - r.length)
s.copy(raw, 64 - s.length)
return raw
}Your login handler calls ePDS's /oauth/par endpoint to register the login
attempt. ePDS returns a short-lived token (request_uri) that identifies this
specific login attempt. You then redirect the user to the auth page with that
token.
ePDS always rejects the first call with a security challenge — your code must catch that and retry with the challenge value included. The code below handles this automatically:
const parBody = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'atproto transition:generic',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
// Flow 1 only — omit for Flow 2:
login_hint: email,
})
// First attempt (will get a 400 with dpop-nonce)
let parRes = await fetch(parEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: dpopProof,
},
body: parBody.toString(),
})
// Retry with nonce if required
if (!parRes.ok) {
const dpopNonce = parRes.headers.get('dpop-nonce')
if (dpopNonce && parRes.status === 400) {
dpopProof = createDpopProof({
privateKey,
jwk: publicJwk,
method: 'POST',
url: parEndpoint,
nonce: dpopNonce,
})
parRes = await fetch(parEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: dpopProof,
},
body: parBody.toString(),
})
}
}
const { request_uri } = await parRes.json()After registering the login attempt, redirect the user's browser to the ePDS auth page. For Flow 1, include the email so ePDS skips its own email form and goes straight to OTP entry:
// Flow 1
const authUrl = `${authEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}&login_hint=${encodeURIComponent(email)}`
// Flow 2
const authUrl = `${authEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`Store the DPoP private key, codeVerifier, and state in a signed HttpOnly
session cookie so the callback handler can retrieve them:
// Before redirecting, save OAuth state in a signed cookie
response.cookies.set('oauth_session', signedSessionCookie, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 600, // 10 minutes — matches PAR request_uri lifetime
path: '/',
})After the user authenticates, ePDS redirects them back to your callback URL
with a short-lived code. Your callback handler checks it's a genuine redirect
from this login attempt (by verifying the state value you stored earlier),
then exchanges the code for an access token:
// Verify state matches what we stored
if (params.state !== sessionData.state) throw new Error('state mismatch')
const { privateKey, publicJwk } = restoreDpopKeyPair(sessionData.dpopPrivateJwk)
const tokenBody = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: sessionData.codeVerifier,
})
// First attempt
let dpopProof = createDpopProof({
privateKey,
jwk: publicJwk,
method: 'POST',
url: tokenEndpoint,
})
let tokenRes = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: dpopProof,
},
body: tokenBody.toString(),
})
// Retry with nonce if required
if (!tokenRes.ok) {
const dpopNonce = tokenRes.headers.get('dpop-nonce')
if (dpopNonce) {
dpopProof = createDpopProof({
privateKey,
jwk: publicJwk,
method: 'POST',
url: tokenEndpoint,
nonce: dpopNonce,
})
tokenRes = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: dpopProof,
},
body: tokenBody.toString(),
})
}
}
const { access_token, sub: userDid } = await tokenRes.json()The sub field in the token response is the user's AT Protocol identity
(a DID, e.g. did:plc:abc123...). You can resolve it to a human-readable
handle via the PLC directory:
const plcRes = await fetch(`https://plc.directory/${userDid}`)
const { alsoKnownAs } = await plcRes.json()
const handle = alsoKnownAs
?.find((u: string) => u.startsWith('at://'))
?.replace('at://', '')
// e.g. "alice.pds.example.com"New users choose their handle during signup (e.g. alice.pds.example.com).
The local part must be 5–20 characters, alphanumeric with hyphens, no dots.
Real-time availability checking is shown in the handle picker UI.
Handles are not derived from the user's email address, for privacy.
Users can change their handle later via account settings.
Even in Flow 1, where your app already has the email, the user still has to be briefly redirected to the ePDS auth page. This is a requirement of the AT Protocol:
- The final authentication step (verifying the OTP) must happen on ePDS's domain, not your app's domain
- Future authentication methods (passkeys, WebAuthn) need to be bound to ePDS's origin — your app's origin won't work for those