Skip to content
View raw

ePDS (extended PDS)

The ePDS adds email-based, passwordless sign-in on top of a standard AT Protocol PDS. Users enter their email, receive a one-time code, and end up with a normal AT Protocol session tied to a DID.

Certified operates production, staging, and test ePDS instances. See Certified services for the current hostnames and guidance on which to use in which scenario.

For applications, the important part is that ePDS still finishes by issuing a standard AT Protocol authorization code. In practice, this means you can integrate it with @atproto/oauth-client-node.

System overview

text
Client App
  -> starts AT Protocol OAuth against the PDS


PDS Core
  -> remains the OAuth issuer and token endpoint
  -> advertises the Auth Service as the authorization endpoint


Auth Service
  -> collects the user's email or OTP
  -> verifies the user
  -> returns control to PDS Core via signed callback


PDS Core
  -> issues a normal authorization code


Client App
  -> exchanges the code for tokens

The PDS remains the OAuth issuer and token endpoint. The main difference is that the authorization step happens on the ePDS Auth Service, which handles the email and OTP flow before returning control to the PDS.

Integrating with @atproto/oauth-client-node

ePDS works with the standard AT Protocol OAuth client libraries. The main ePDS-specific behavior is how you shape the authorization URL before redirecting the user.

Flow 1: your app collects the email

In Flow 1, your app has its own email field. Start OAuth normally, then add login_hint=<email> to the authorization URL before redirecting the user.

ts
import { NodeOAuthClient } from '@atproto/oauth-client-node'


const oauthClient = new NodeOAuthClient({
  clientMetadata: {
    client_id: 'https://yourapp.example.com/client-metadata.json',
    client_name: 'Your App',
    client_uri: 'https://yourapp.example.com',
    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,
  },
  stateStore,
  sessionStore,
})


const url = await oauthClient.authorize('alice.certified.one', {
  scope: 'atproto transition:generic',
})


// ePDS-specific customization happens here.
const authUrl = new URL(url)
authUrl.searchParams.set('login_hint', email)
authUrl.searchParams.set('epds_handle_mode', 'picker-with-random')


return authUrl.toString()

Do not put an email address into the PAR body as login_hint. For ePDS, add login_hint to the authorization URL instead.

With login_hint set, the user lands directly on the OTP entry step instead of first seeing an email form on ePDS.

Flow 2: ePDS collects the email

In Flow 2, your app just shows a "Sign in" button. Start OAuth normally and redirect the user to the authorization URL without login_hint.

ts
import { NodeOAuthClient } from '@atproto/oauth-client-node'


const oauthClient = new NodeOAuthClient({
  clientMetadata: {
    client_id: 'https://yourapp.example.com/client-metadata.json',
    client_name: 'Your App',
    client_uri: 'https://yourapp.example.com',
    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,
  },
  stateStore,
  sessionStore,
})


const url = await oauthClient.authorize('alice.certified.one', {
  scope: 'atproto transition:generic',
})


const authUrl = new URL(url)
authUrl.searchParams.set('epds_handle_mode', 'picker')


return authUrl.toString()

Without login_hint, ePDS renders its own email form and takes the user through the rest of the OTP flow.

Callback handling

Callback handling stays standard. Once the user finishes on ePDS, your callback handler receives a normal authorization code and hands it back to oauth-client-node.

ts
const result = await oauthClient.callback(params)


const session = result.session
const did = session.did

Handle modes

Handle mode controls what happens when a brand new user needs a handle during signup.

ModeBehavior
picker-with-randomShow the handle picker with a "Generate random" option.
pickerShow the handle picker without a random option.
randomSkip the picker and assign a random handle automatically.

Handle mode is resolved in this order:

  1. epds_handle_mode query param on the authorization URL
  2. epds_handle_mode in client metadata
  3. The ePDS instance default (EPDS_DEFAULT_HANDLE_MODE)

This only affects new account creation. Existing users keep their current handle and skip this step.

Client metadata

Your client metadata file is a public JSON document served over HTTPS. Its URL is also your client_id.

Bare-bones example

JSON
{
  "client_id": "https://yourapp.example.com/client-metadata.json",
  "client_name": "Your App",
  "client_uri": "https://yourapp.example.com",
  "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
}

Full config example

JSON
{
  "client_id": "https://yourapp.example.com/client-metadata.json",
  "client_name": "Your App",
  "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,
  "brand_color": "#0f172a",
  "background_color": "#ffffff",
  "email_template_uri": "https://yourapp.example.com/email-template.html",
  "email_subject_template": "{{code}} - Your {{app_name}} code",
  "branding": {
    "css": "body { background: #0f172a; color: #e2e8f0; }"
  },
  "epds_handle_mode": "picker-with-random"
}

The extra branding fields customize the hosted login and email experience. epds_handle_mode sets your preferred handle mode for new users unless you override it on the authorization URL.

Branding and customization

How branding works

ePDS reads branding settings from your app's client-metadata.json, using the OAuth client_id to look it up. Standard metadata fields like logo_uri, brand_color, background_color, email_template_uri, and email_subject_template customize the hosted login and email experience.

Trusted clients can go further by adding custom CSS in client metadata under branding.css:

JSON
{
  "branding": {
    "css": "body { background: #0f172a; color: #e2e8f0; }"
  }
}

When the client is trusted, ePDS injects that CSS into its hosted auth pages and the stock consent page.

Trust is checked against the exact client_id.

The client_id you send during OAuth, the client_id inside client-metadata.json, and the entry in PDS_OAUTH_TRUSTED_CLIENTS must all be identical.

For example, if your client metadata says "client_id": "https://hypercerts-scaffold.vercel.app/client-metadata.json", then PDS_OAUTH_TRUSTED_CLIENTS must contain https://hypercerts-scaffold.vercel.app/client-metadata.json — not just https://hypercerts-scaffold.vercel.app. See the Scaffold Starter App for a concrete example of a client serving metadata from /client-metadata.json.

Client metadata branding fields

These fields are the main branding controls exposed through client metadata:

FieldWhat it affects
logo_uriApp logo shown in hosted auth and email flows
brand_colorPrimary brand color used by hosted screens
background_colorBackground color for hosted screens
email_template_uriCustom HTML template for OTP emails
email_subject_templateSubject line template for OTP emails
branding.cssCustom CSS for trusted clients

CSS injection for trusted clients

Custom CSS is only applied for clients whose exact client_id appears in PDS_OAUTH_TRUSTED_CLIENTS. When present, ePDS injects a <style> tag into the rendered page, sanitizes the CSS to prevent </style> tag closure, and updates the page's CSP style-src directive with a SHA-256 hash for the injected stylesheet.

This gives operators a safety boundary: untrusted clients never get CSS injection, even if their metadata contains branding CSS.

Where branding appears

The send-OTP and initial-OTP screens are two states of the same auth-service route: https://auth.epds1.test.certified.app/oauth/authorize.

SurfaceURLSupports branding
Send OTPhttps://auth.epds1.test.certified.app/oauth/authorizeMetadata fields + trusted-client CSS
Initial OTPhttps://auth.epds1.test.certified.app/oauth/authorizeMetadata fields + trusted-client CSS
Choose handlehttps://auth.epds1.test.certified.app/auth/choose-handleMetadata fields + trusted-client CSS
Recoveryhttps://auth.epds1.test.certified.app/auth/recoverMetadata fields + trusted-client CSS
Consent pagehttps://epds1.test.certified.app/oauth/authorizeTrusted-client CSS

Examples

Send OTP

Stock

Stock send OTP screen

CSS injected

CSS-injected send OTP screen

Initial OTP

Stock

Stock initial OTP screen

CSS injected

CSS-injected initial OTP screen

Choose handle

Stock

Stock choose handle screen

CSS injected

CSS-injected choose handle screen

Consent page

Stock

Stock consent page

CSS injected

CSS-injected consent page

Recovery

Stock

Stock recovery screen

CSS injected

CSS-injected recovery screen

Further reading