- End users:
-
#154
2e4d327Thanks @aspiers! - Sign-in pages no longer strand users on a "session expired" dead end, and Resend no longer offers codes that won't work.Affects: End users
End users: if your sign-in times out (you closed the tab and came back, or your wait was longer than the page can keep alive in the background), you are now taken back to the app you were signing in to so it can offer you a retry. The page also no longer offers Resend in the rare case where the new code wouldn't work — instead it tells you the sign-in has timed out and gives you a Start over button. No more typing a fresh code that fails. If for some reason the automatic return is not possible, the page shows a "Return to sign in" button so you can get back to the app yourself in one click.
-
#154
b1fc940Thanks @aspiers! - Slow sign-ins are less likely to time out before you finish entering your code.Affects: End users
End users: if you take a few minutes to find your sign-in code in your inbox before entering it, you will no longer be bounced to a "session expired" page when you submit it. Closing the tab or walking away for a long stretch can still expire the flow, in which case the existing error pages still apply — but reading email at human speed should not.
- End users:
- "Powered by Certified" footer now appears on every auth-service page.
- "Use a different account" on the chooser now reliably takes you to the email form, not the code step for the previous account.
- Sign-in no longer fails with "Authentication session expired" when an OTP code is resent after the original code times out.
- Terms of Use and Privacy Policy links on the sign-in page now open in a new tab.
- OAuth consent buttons stack cleanly on small screens.
- A smoother sign-in code experience: no false error flash on a successful sign-in, no rapid-fire failures when correcting a wrong code, and tidier-looking banners.
- Sign-in no longer fails with a raw JSON error page when a user takes too long on the OTP step.
- Sign-in no longer hits a dead-end on the password form
- Client app developers:
-
#130
6a8671dThanks @s-adamantine! - "Powered by Certified" footer now appears on every auth-service page.Affects: End users
End users: every page rendered by the auth service now displays the same "Powered by Certified" footer that the main sign-in already shows, so the branding is consistent end-to-end. New surfaces covered: the Account Settings sign-in flow at
/account/login(email-entry and code-entry steps), the "Choose your handle" page shown to new users after email verification, both Account Recovery steps (backup-email entry and recovery-code entry), the/accountsettings dashboard, the backup-email verification confirmation, the post-deletion confirmation, and the generic error pages used by 404 / 500 / session-expired flows. -
#141
899346cThanks @aspiers! - "Use a different account" on the chooser now reliably takes you to the email form, not the code step for the previous account.Affects: End users
End users: when you click "Another account" on the account chooser to sign in as someone else, you now always land on a fresh email entry form. Previously, if the app that started the sign-in had pre-filled an account hint, the page jumped straight to the verification-code step for the previous account — leaving you stuck typing a code for an account you were trying to leave.
-
#122
dacf1d2Thanks @aspiers! - Sign-in no longer fails with "Authentication session expired" when an OTP code is resent after the original code times out.Affects: End users
End users: Previously, if you took longer than 10 minutes to enter the one-time code emailed to you and then clicked Resend code, the new code would verify, but the next page would say "Authentication session expired. Please try again." and you would have to start the whole sign-in over. The OAuth session that was tracking your sign-in had the same 10-minute lifetime as the OTP code itself, so it had already gone away by the time the new code arrived.
The OAuth session now lives long enough to outlast a typical resend cycle, so a slow first attempt followed by Resend completes normally. The OTP code's own 10-minute lifetime is unchanged.
-
#127
8bf888bThanks @s-adamantine! - Terms of Use and Privacy Policy links on the sign-in page now open in a new tab.Affects: End users
End users: clicking Terms of Use or Privacy Policy on the sign-in page no longer navigates away from the in-progress sign-in. The links open in a new tab instead, so you can read the legal page and come back to finish signing in without restarting.
-
#136
143ff35Thanks @Kzoeps! - OAuth consent buttons stack cleanly on small screens.Affects: End users
End users: On phones and narrow browser windows, the consent screen now places the approve and deny buttons on separate lines so they are easier to read and tap. Larger screens keep the existing button layout.
-
#134
bce65b5Thanks @s-adamantine! - A smoother sign-in code experience: no false error flash on a successful sign-in, no rapid-fire failures when correcting a wrong code, and tidier-looking banners.Affects: End users, Client app developers
End users:
- A successful sign-in no longer briefly shows a red "Invalid OTP" message on its way to signing you in.
- After entering a wrong code, the boxes clear and focus jumps back to the first one, so retyping doesn't immediately resubmit the still-wrong code on every keystroke (which previously could lock you out for spamming the server).
- The red "Invalid OTP" and green "Code resent" banners are centred inside their coloured container instead of sitting in the corner of an empty wide box.
Client app developers: the sign-in page's flash-message container now uses a stable
flash-msgbase class witherror/successmodifier classes, so custom client CSS can restyle either variant cleanly via.flash-msg,.flash-msg.error, and.flash-msg.success. -
#128
0e62bd6Thanks @aspiers! - Sign-in no longer fails with a raw JSON error page when a user takes too long on the OTP step.Affects: End users
End users: Previously, if you took more than five minutes between requesting your one-time code and submitting it (a slow inbox, switching tabs, fishing the code out of spam, multiple Resend cycles), sign-in could fail with a blank page showing only
{"error": "Authentication failed"}on the PDS host — even though your OTP code itself was still valid. You now either land back inside the app you were signing into (which can offer a one-click retry), or see a styled error page on the PDS host explaining that sign-in timed out — depending on how far through the flow the timeout is detected. Either way, no more raw JSON. -
#129
14e5033Thanks @aspiers! - Sign-in no longer hits a dead-end on the password formAffects: End users
End users: if you saw a "handle and password" form during sign-in with no way to enter a code, that path is gone. The email-code form will be shown instead, and after entering the code you'll be signed in normally.
- End users:
- Auth-service login page can now offer ATProto/Bluesky handle sign-in alongside email OTP.
- Trusted apps can now show their own icon in the browser tab on the sign-in page.
- Refreshed sign-in page design, with new ways for apps to style it.
- Account settings page now shows your current handle.
- Sign-in, account, error, and OAuth-consent pages now show an icon in the browser tab, with separate assets for light and dark browser themes.
- Signing in once in your browser now works across all apps that use this ePDS.
- Account recovery via backup email now completes the OAuth flow instead of dropping users into signup.
- Visiting the bare auth service URL now takes you to the account page instead of a blank 404.
- Error pages on the sign-in service now match the rest of the signup and login look instead of showing plain default text, and apps calling the sign-in service now receive structured error responses by default instead of HTML pages.
- Client app developers:
- Auth-service login page can now offer ATProto/Bluesky handle sign-in alongside email OTP.
- Preview ePDS's auth-service screens and emails directly in your browser, without walking through the OAuth flow.
- Trusted apps can now show their own icon in the browser tab on the sign-in page.
- Trusted demo client now ships with a custom branded OTP email template.
- Refreshed sign-in page design, with new ways for apps to style it.
- Signing in once in your browser now works across all apps that use this ePDS.
- Security fix: client-supplied email templates now require the client to be on the trusted-clients list.
- Error pages on the sign-in service now match the rest of the signup and login look instead of showing plain default text, and apps calling the sign-in service now receive structured error responses by default instead of HTML pages.
- Operators:
- Auth-service login page can now offer ATProto/Bluesky handle sign-in alongside email OTP.
- Preview ePDS's auth-service screens and emails directly in your browser, without walking through the OAuth flow.
- Trusted apps can now show their own icon in the browser tab on the sign-in page.
- Trusted demo client now ships with a custom branded OTP email template.
- Refreshed sign-in page design, with new ways for apps to style it.
- Sign-in, account, error, and OAuth-consent pages now show an icon in the browser tab, with separate assets for light and dark browser themes.
- Signing in once in your browser now works across all apps that use this ePDS.
- Fix a pds-core crash on the account chooser (
/account) caused by response-rewrite middleware running after upstream had already flushed headers. - Security fix: client-supplied email templates now require the client to be on the trusted-clients list.
- Auth-service rate limiter can now be disabled for single-source-IP test environments.
- Account recovery via backup email now completes the OAuth flow instead of dropping users into signup.
- Visiting the bare auth service URL now takes you to the account page instead of a blank 404.
-
#115
7f265b7Thanks @aspiers! - Auth-service login page can now offer ATProto/Bluesky handle sign-in alongside email OTP.
Affects: End users, Client app developers, Operators
End users:
- When the app you came from supports it, the sign-in page now shows an "Or sign in with ATProto/Bluesky" button under the email form.
- Clicking the button switches the form into handle-entry mode (e.g.
you.bsky.social). Submitting a handle takes you back to your own PDS to finish signing in there. - Clicking the button again returns you to the email form.
Client app developers: opt in by adding
epds_handle_login_urlto your OAuth client metadata.- The value must be an absolute https:// URL on your client's own origin. ePDS auth-service redirects the browser to that URL with
?handle=<value>appended when the user submits a handle. - Your route is responsible for resolving the handle to its PDS and starting a fresh OAuth flow against that PDS — auth-service is bound to one PDS and cannot start a PAR on your client's behalf, so off-PDS handles only work via this hand-off.
- The reference demo client opts in by exposing
${baseUrl}/api/oauth/login?handle=..., which already accepts ahandlequery parameter and resolves it dynamically. - If you do not declare
epds_handle_login_url, the button is not rendered. Existing clients see no behaviour change.
Operators: no new required configuration. The button only renders for OAuth clients that explicitly opt in via their metadata.
-
#103
226781b/ #93d363b3dThanks @aspiers! - Preview ePDS's auth-service screens and emails directly in your browser, without walking through the OAuth flow.
Affects: Client app developers, Operators
Client app developers:
A new preview route on pds-core renders the account chooser with fixture sessions and your branding CSS, alongside the existing
/preview/consentroute. Open/preview/chooser(linked from the/previewindex) to see how a returning user with one or more bound accounts will see your client. Inline controls on the index let you tweak the preview without editing the URL: a number field for?numAccounts=N(clamped to 1–10) grows or shrinks the fixture account list, and a dropdown for?epds_handle_mode=overrides the handle-picker mode the same way a real OAuth request can. The dropdown defaults to "Auto", which omits the param so client metadata (or the operator's env default) wins — exactly the production resolver order. The same?client_id=<URL-of-your-client-metadata.json>param the other preview routes accept also injects your branding CSS, subject to the standard trusted-clients gate. The existing/preview/choose-handlelink on the auth-service index gains the same?epds_handle_mode=and?error=dropdowns and collapses the four enumerated handle-mode entries into a single link with bound controls.Three new preview routes on the auth service render the exact email HTML real users receive, inside a sandboxed iframe:
/preview/emails/new-user— welcome / email-verification code sent during signup./preview/emails/returning-user— sign-in OTP sent when an existing user logs in to your app./preview/emails/recovery— backup-email verification link sent when a user adds a recovery address.
Each route accepts the same
?client_id=<URL-of-your-client-metadata.json>query param as the other preview pages, so you can see how your branded template will look without walking through a real OAuth flow. Optional extras:?otp=<code>to override the fixture OTP,?app=<name>to override the fixture app name on the returning-user template,?verify_url=<url>to override the backup-email verification link. Links for all three are wired into the/previewindex page on the auth service.Operators: the chooser route is gated by the existing
PDS_PREVIEW_ROUTES=1flag on pds-core, and the email routes by the existingAUTH_PREVIEW_ROUTES=1flag on the auth service — no new environment variables. When the flags are off the new routes return 404, identical to the rest of/preview/*. The email previews do not touch SMTP; they call the same template builders the real sender uses, so what renders is bit-for-bit what production would put in the envelope. Intended for preview and development environments; leave the flags off in production. -
#86
21a8befThanks @s-adamantine! - Trusted apps can now show their own icon in the browser tab on the sign-in page.Affects: End users, Client app developers, Operators
End users: When signing in to a trusted app, the browser tab on the sign-in, recovery, and handle-picker pages will display that app's icon instead of the default ePDS icon. No action required.
Client app developers: Add a
favicon_urlfield (and optionallyfavicon_url_dark) underbrandingin your OAuth client metadata document. Each URL must be an absolutehttps://URL (nohttp://, nodata:URIs, no userinfo credentials), at most 2048 characters, and must share an origin (scheme + host + port) with yourclient_id. When both light and dark variants are supplied, ePDS emits two<link rel="icon">tags gated byprefers-color-schemeso browsers automatically pick the variant matching the user's OS theme. When only the light variant is supplied, a single bare<link>is emitted and the browser uses it for both schemes. The browser fetches the favicons directly, so they must be reachable from end-user browsers and served with an appropriateContent-Type(image/svg+xml,image/png,image/x-icon, etc.). URLs failing any check are dropped — the page falls back to the default ePDS favicon, and a warning is logged server-side identifying the offendingclient_id. Example client metadata snippet for aclient_idofhttps://myapp.example/client-metadata.json:{ "client_name": "My App", "branding": { "css": "...", "favicon_url": "https://myapp.example/favicon.svg", "favicon_url_dark": "https://myapp.example/favicon-dark.svg" } }The same-origin requirement exists because the auth-service Content-Security-Policy only widens
img-srcto theclient_idorigin. A favicon hosted on a separate CDN domain would be silently blocked by the browser, so we reject it server-side instead and log it, giving operators a clear breadcrumb. To use a favicon hosted off-origin, host or proxy it under theclient_idorigin (e.g. via a/favicon.svgpath on the same hostname that serves your client metadata).Favicon injection is gated by the same
PDS_OAUTH_TRUSTED_CLIENTSallowlist asbranding.css— untrusted clients' favicons are ignored.Operators: No new environment variables. The existing
PDS_OAUTH_TRUSTED_CLIENTSallowlist now also gates favicon injection in addition to CSS injection. To opt a client into custom favicons, add theirclient_idURL to that comma-separated list as before. Operators do not need to host or proxy any client icons — they are loaded by the end user's browser directly from the URL the client provides. -
#93
03ebf36Thanks @aspiers! - Trusted demo client now ships with a custom branded OTP email template.
Affects: Client app developers, Operators
Client app developers: the demo client's
client-metadata.jsonnow advertisesemail_template_uri(pointing at/email-template.htmlon the same origin) andemail_subject_template({{code}} — your {{app_name}} code), so operators running ePDS with the demo as a trusted client see a visually coherent login + email experience out of the box. The template is a minimal Mustache-style HTML email that respects the demo'sEPDS_CLIENT_THEMEpalette: the OTP box, headings, and background all match whichever theme is active on the login and consent pages. Copy the shape frompackages/demo/src/app/email-template.html/route.tsif you want a starting point for your own client's branded template — the supported placeholders are{{code}},{{app_name}},{{logo_uri}},{{email}}, and the conditional blocks{{#is_new_user}}…{{/is_new_user}}/{{^is_new_user}}…{{/is_new_user}}.Operators: no env var change is required — the demo's branded email is served automatically when you run the bundled demo client as a trusted client on
PDS_OAUTH_TRUSTED_CLIENTS. The template is served from the demo's own origin (<demo-base-url>/email-template.html) withCache-Control: public, max-age=300, is capped at the same 100 KB / 5 s limitsmakeSafeFetchapplies to any remote email template, and is only honoured forclient_ids on the trusted-clients list (see thegate-email-templates-on-trusted-clientschangeset). You can verify what your users will receive by opening/preview/emails/returning-user?client_id=<demo-base-url>/client-metadata.jsonon the auth service withAUTH_PREVIEW_ROUTES=1. -
#110
f4f1040Thanks @s-adamantine! - Refreshed sign-in page design, with new ways for apps to style it.Affects: End users, Client app developers, Operators
End users: The sign-in page is now a white card centered on a muted grey background, with rounded inputs, pill-shaped buttons, and a "Powered by Certified" footer. The one-time code step uses six segmented input boxes (with paste, arrow, backspace, and auto-submit) instead of a single text field. The underlying sign-in flow is unchanged.
Client app developers: The login page now exposes its surface colors as CSS custom properties for trusted clients to override from their injected
branding.css::root { --page-bg: #YOUR_OUTER_BG; /* page bg outside the card; default #E8E8E8 */ --card-bg: #YOUR_CARD_BG; /* card surface; default #F8F8F8 */ --input-bg: #YOUR_INPUT_BG; /* email + OTP box backgrounds; default #ffffff */ --input-border: #YOUR_INPUT_BORDER; /* email + OTP box borders; default #e5e5e5 */ --card-border: #YOUR_CARD_BORDER; /* card outline; default #E5E5E5 */ --btn-secondary-border: #YOUR_BTN_BORDER; /* social / ATProto button borders; default #e5e5e5 */ --muted-foreground: #YOUR_MUTED_TEXT; /* terms text + "Powered by" tint; default #999 */ --focus-border: #YOUR_FOCUS; /* defaults to your client metadata's brand_color */ }
The page no longer reads
background_colorfrom your client metadata — to control the page background, set--page-bgfrom yourbranding.cssinstead. Pre-existing trusted clients that relied onbackground_colorfor the login bg need to migrate to the CSS var; clients that only usedbackground_colorfor other rendered pages are unaffected.The "Recover with backup email" link on the OTP step is shown by default. To suppress it (e.g. for a client that doesn't surface backup-email recovery), set
:root { --recovery-link-display: none; }in yourbranding.css. The recovery flow at/auth/recoveris reachable via direct navigation regardless — only the entry point on the login page is hidden.Operators: a new terms-of-use / privacy-policy line renders below the card, driven by environment variables. Set
PDS_TERMS_OF_SERVICE_URLandPDS_PRIVACY_POLICY_URL(the same vars upstream PDS reads, so they only need to be set once per deployment) to enable the line; if either is missing the line is omitted entirely. The optionalPDS_LEGAL_ENTITY_NAMEcontrols the possessive — when set, the copy reads "By signing in, you agree to 's Terms of Use and Privacy Policy."; when unset, "By signing in, you agree to the Terms of Use and Privacy Policy."The upstream
@atproto/oauth-provider-uiconsent + chooser pages served by pds-core now ship with default Certified-style CSS injected by pds-core, so an unbranded ePDS deployment renders coherently with the auth-service login page (neutral grey page bg, light card surface, dark primary button) instead of the upstream's purple-on-white defaults. Trusted-clientbranding.csscontinues to override via cascade order — no client opt-in or migration needed. -
#99
5b74ce2Thanks @aspiers! - Account settings page now shows your current handle.Affects: End users
End users: Visiting the account settings dashboard at
/accounton the auth service (not the PDS itself) now displays a "Current Handle:" row above the handle update form, so you can see at a glance what your current AT Protocol handle is before changing it. The auth service resolves the handle by calling the PDS'scom.atproto.repo.describeRepoXRPC on every request, so the row reflects the authoritative value — including any pending rename that hasn't propagated to a local cache. If the PDS can't be reached the row displays(unknown)and the rest of the page still renders.
-
#85
d48f735Thanks @s-adamantine! - Sign-in, account, error, and OAuth-consent pages now show an icon in the browser tab, with separate assets for light and dark browser themes.Affects: End users, Operators
End users: When signing in, recovering an account, choosing a handle, managing account settings, landing on an error page, or seeing the OAuth consent preview, your browser tab now displays a small icon next to the page title instead of the browser's generic placeholder. The icon automatically switches between a light- and dark-theme variant to match your browser's color scheme.
Operators: both the auth service and pds-core now reference
/static/favicon.svgand/static/favicon-dark.svgfrom every rendered page<head>, gated byprefers-color-schememedia queries. Both files ship by default inpackages/auth-service/public/andpackages/pds-core/public/(each service serves its own copy under its own origin). To use your own icons, replace those files (any SVG will do) — no config change required. The existing/staticmounts inpackages/auth-service/src/index.tsandpackages/pds-core/src/index.tsserve them automatically. Each service also aliases/favicon.icoto its light-theme SVG so browsers that auto-request the legacy path on non-HTML responses (e.g./health, XRPC JSON) still get an icon; the alias is single-variant becauseprefers-color-schemeonly works via<link>tags in a real<head>.Upstream
@atproto/oauth-provider-rendered pages (the account chooser at/account*, the OAuth authorize flow at/oauth/*, and upstream error pages) are also covered via a response-rewrite middleware that prepends the same two favicon<link>tags into the<head>of those responses. Same single-tenant asset as the auth-service pages: replacepackages/pds-core/public/favicon*.svgto customise. -
#96
1bf9ce1Thanks @aspiers! - Signing in once in your browser now works across all apps that use this ePDS.Affects: End users, Client app developers, Operators
End users:
- After you sign in once with any app that uses this ePDS, a second app asking you to sign in skips the email code step.
- Depending on the app, you either land straight on the "approve this app" screen or on an account chooser where you confirm which identity to reuse.
- A "Use a different account" link on the chooser takes you back to the email form for a fresh sign-in.
- The chooser shows your email next to your handle so accounts are easy to tell apart.
- If your browser's leftover sign-in cookies no longer match the server, you land on the familiar email code form rather than a generic sign-in screen.
- If an app asks you for your email and you give it one that is not one of the accounts you have already signed in to in this browser, you go straight to the email code form for that account rather than landing on a chooser of your existing accounts.
Client app developers: no client-side changes required.
- When a previous sign-in's cookies are present, the user lands on the account chooser to confirm which identity to reuse.
- When you set
login_hintto an email, AT Protocol handle, or DID, ePDS checks whether the hinted account is bound to the current device. If it is, the chooser still appears (with the hinted account pre-selected). If not, session reuse is disabled for this single request and the user receives an OTP for the hinted account; other accounts on the device remain reusable on subsequent un-hinted visits — no cookies are cleared. - To force the email code form instead, append
&prompt=loginto the authorization URL the user is redirected to. ePDS reads this from the URL query string, not from the PAR body — see theepds-loginskill for details.
Operators: no new required configuration.
- ePDS auto-detects whether the auth service shares a parent domain with the PDS (
AUTH_HOSTNAMEends with.<PDS_HOSTNAME>) and broadens the device-session cookies to that parent so both services can read them. On unrelated hostnames (e.g. Railway preview envs underup.railway.app) the feature self-disables. - Untrusted OAuth clients should be wired as confidential (
token_endpoint_auth_method=private_key_jwt) for the "remember previous approval" path to work. The reference docker stack does this automatically andscripts/setup.shgenerates the necessary keypairs on first run.
-
#96
1bf9ce1Thanks @aspiers! - Fix a pds-core crash on the account chooser (/account) caused by response-rewrite middleware running after upstream had already flushed headers.Affects: Operators
Operators: The chooser-enrichment and client-CSS-injection middlewares could crash pds-core with
ERR_HTTP_HEADERS_SENTon routes where upstream@atproto/oauth-providerflushes headers beforeres.end()(notably/account). Docker'srestart: unless-stoppedmasked this as a transient 502 — users saw a blank page and the container restarted in the background. Both middlewares now skip their Content-Length / ETag rewrites once the response has started. No configuration change required. -
#95
b04aebfThanks @aspiers! - Security fix: client-supplied email templates now require the client to be on the trusted-clients list.Affects: Client app developers, Operators
Client app developers:
email_template_uri,email_subject_template, and theclient_name-derivedFrom:display name on OTP emails are now only honoured for clients whoseclient_idis on the PDS'sPDS_OAUTH_TRUSTED_CLIENTSlist — matching the gate that already applied to CSS branding injection. Untrusted clients receive the default ePDS OTP template with the defaultFrom:name. If your client isn't on the operator's trust list, advertising these fields inclient-metadata.jsonhas no effect; ask the operator to add yourclient_idto their trusted list.Operators:
PDS_OAUTH_TRUSTED_CLIENTSnow gates email-template branding as well as CSS injection. No config change is required — the same list is reused. If you have been relying on an untrusted client'semail_template_urito style OTP emails (no known such case, but worth checking), add thatclient_idtoPDS_OAUTH_TRUSTED_CLIENTSto restore the previous behaviour. Without this fix, any registeredclient_idcould (a) cause the auth service to fetch an attacker-chosen URL on every OTP send, (b) ship attacker-authored HTML in an email sent from the PDS's ownnoreply@address, and (c) spoof the sender display name viaclient_name.EMAIL_TEMPLATE_ALLOWED_DOMAINSstill applies as an additional narrowing for trusted-client template hosts. -
#103
3ccb48dThanks @aspiers! - Auth-service rate limiter can now be disabled for single-source-IP test environments.Affects: Operators
Set
EPDS_DISABLE_RATE_LIMIT=trueto bypass the per-IP limiter (60 req/min) on the auth service. Only safe where every request shares one source IP (docker-compose, e2e). Leave unset in production. -
#98
260113bThanks @aspiers! - Account recovery via backup email now completes the OAuth flow instead of dropping users into signup.Affects: End users, Operators
End users: signing in via the "Recover account" link and a verified backup email now redirects back to the app you came from, with a session on your real account. Previously the recovery flow would finish the OTP step and then take you to the handle-picker page as if you were a new user, leaving you stuck.
Operators: no configuration changes. The bridge route
/auth/completenow resolves a session's verified email through thebackup_emailtable when there's no direct PDS account for that address, then looks up the primary email via the internal_internal/account-by-handleendpoint. No new environment variables, secrets, or network calls that operators need to allow beyond what auth-service already makes to pds-core. -
#102
548f4adThanks @aspiers! - Visiting the bare auth service URL now takes you to the account page instead of a blank 404.Affects: End users, Operators
End users: Opening the auth service at its root URL (e.g.
https://auth.example.com/) now redirects to the account dashboard. If you are signed in you land on/account; if you are not,/accountbounces you on to/account/loginas before. Previously the root path had no handler and returned a 404 "Cannot GET /" page, which was confusing when bookmarking or mistyping a URL.Operators: The auth service now returns a
303 See OtherwithLocation: /accountforGET /. If you have an external healthcheck pointed at/expecting a 404, switch it to/health(which already exists and returns a JSON status body)./healthis unchanged. -
#97
f76a771Thanks @s-adamantine! - Error pages on the sign-in service now match the rest of the signup and login look instead of showing plain default text, and apps calling the sign-in service now receive structured error responses by default instead of HTML pages.Affects: End users, Client app developers
End users: When a sign-in URL can't be found or something goes wrong on the sign-in service, the page shown now uses the same branded card layout as the rest of the sign-in flow, rather than the framework's unstyled default error page. The same applies to validation screens inside
/accountsettings when a required field is missing or a verification link is malformed.Client app developers: The auth-service 404 and 500 handlers now do proper
Acceptheader negotiation. Previously they returned HTML whenever the client would accept it — includingAccept: */*, whichfetchandcurlsend by default — so programmatic callers received HTML error bodies. The handlers now usereq.accepts(['json', 'html'])and only return HTML when the client explicitly prefers it; anything else (including*/*) returns the existing JSON shape{ "error": "not_found" | "internal_error" }. If you were parsing HTML error responses from auth-service, switch to the JSON shape, or sendAccept: text/htmlexplicitly to opt back into HTML.
- Client app developers & operators:
- End users of the trusted demo:
-
#84
fe3ec90Thanks @aspiers! - Add preview routes on auth-service and pds-core for iterating on client branding CSS.Affects: Client app developers, Operators
Client app developers:
- Visit
/previewon either auth-service or pds-core for an index of every preview page. Each page renders against fixture data, so you can iterate on yourbranding.csswithout walking through a real OAuth flow. - Paste your
client-metadata.jsonURL into the input field on the index page. The value is persisted in your browser and wires up every preview link, subject to the samePDS_OAUTH_TRUSTED_CLIENTScheck as a real flow. Leave it blank to see the unbranded baseline. - The workflow becomes: edit
branding.css, refresh any preview page. No OTP emails, no full flow. - The demo app links directly to the auth-service preview index with its own
client_idpre-selected.
Operators:
- Two new env vars gate the preview routes, one per service:
AUTH_PREVIEW_ROUTES=1on auth-service,PDS_PREVIEW_ROUTES=1on pds-core. Both are independent. - Safe to enable on preview deployments (Railway PR previews,
pr-base, dev) and on local development instances. Preview routes don't affect real auth flows — they short-circuit real state — so they can technically run in production too, but they are a developer-only surface and are best left off outside preview/dev envs. - Privacy: enabling previews exposes
/preview/cache-status, which returns the list ofclient_idURLs currently in the shared client-metadata cache — i.e. apps that have recently started an OAuth flow against this PDS. That partially leaks which third-party clients are using the instance, so keep previews disabled in production unless you're comfortable with that. - See
packages/auth-service/.env.exampleandpackages/pds-core/.env.examplefor the full notes.
- Visit
-
#83
cc722c4Thanks @aspiers! - Demo amber/ocean themes now colour the OAuth consent page correctly.Affects: End users of the trusted demo
End users: The consent screen shown after signing in via the trusted demo now uses the demo's own warm indigo / amber palette throughout — the Authorize and Deny-access buttons, the "Authorize" header strip, and the surrounding surface all match the theme instead of falling back to the default @atproto/oauth-provider dark-mode look.
The previous CSS targeted auth-service's hand-rolled login markup (
.btn-primary,.container,.field), which does not exist on the consent page — that page is built from@atproto/oauth-provider-ui, which is a Tailwind-utility bundle whose colours are driven by CSS custom properties (--branding-color-primaryand friends). The demo theme now overrides those variables at:root, so a single declaration recolours everybg-primary/text-primary/border-primaryutility on the consent page at once, and additionally paints the card surface and body background to match. -
#89
1942ebbThanks @aspiers! - Fix two preview-route cache bugs and remove long-stale debug endpoints.Affects: Client app developers, Operators
Client app developers:
- Preview-route fetch failures no longer poison the shared client-metadata cache. Previously, a failed preview fetch for a
client_idwith a valid 10-minute entry would overwrite that entry with a 60-second branding-less fallback, silently droppingbranding.csson real OAuth flows for up to a minute. The in-memory cache is now only written by real-flow resolution. - The auth-service HTML preview pages (
/preview/login,/preview/login-otp,/preview/choose-handle,/preview/choose-handle-picker,/preview/recovery,/preview/recovery-otp, and the/previewindex) now sendCache-Control: no-store. Without it, a browser refresh could serve a cached page and never ask the server for freshbranding.css, breaking the advertised "editbranding.css, refresh the preview page" workflow. /preview/validatenow flagsbranding.csswhose escaped size exceeds the 32 KB injection limit as an error, instead of reportingokand letting the developer discover later that their CSS was silently dropped on real OAuth flows. Byte counts now matchgetClientCss()'s measurement (escaped UTF-8).
Operators:
- Removed
/_internal/debug-grantsand/_internal/debug-recent-accounts. These were added as temporary HYPER-270 debugging endpoints with a code comment marking them for removal before PR #21 shipped (v0.2.2); they survived through v0.2.2, v0.3.0, v0.4.0, and the pending v0.5.0. The matching env varEPDS_DEBUG_GRANTSis no longer read.
- Preview-route fetch failures no longer poison the shared client-metadata cache. Previously, a failed preview fetch for a
- End users:
- Client app developers:
- Operators:
-
#48
0c275e4Thanks @Kzoeps & @aspiers! - Trusted apps can now style the sign-in and consent pages to match their own brand.Affects: End users, Client app developers, Operators
End users: When signing in through an app that your ePDS operator has approved for branding, the login page, code entry page, handle picker, account recovery page, and consent page will display that app's colour scheme instead of the default look. The pages still work exactly the same way — only the visual appearance changes.
Client app developers: Add a
branding.cssfield inside abrandingobject in yourclient-metadata.json. The CSS is injected as a<style>tag into every auth-service page and the PDS stock consent page (/oauth/authorize) when yourclient_idis listed in the operator'sPDS_OAUTH_TRUSTED_CLIENTS. The CSS is size-capped at 32 KB (measured in escaped UTF-8 bytes) and sanitised to prevent</style>tag closure. The CSPstyle-srcdirective is updated with a SHA-256 hash of the injected CSS. Example metadata:{ "client_id": "https://app.example/client-metadata.json", "client_name": "My App", "branding": { "css": "body { background: #0f1b2d; color: #e2e8f0; } .btn-primary { background: #3b82f6; }" } }Untrusted clients (not in
PDS_OAUTH_TRUSTED_CLIENTS) never get CSS injection, regardless of what their metadata contains.Operators: CSS branding injection is controlled by the existing
PDS_OAUTH_TRUSTED_CLIENTSenv var on pds-core. No new env vars are required on pds-core or auth-service. The auth-service reads the samePDS_OAUTH_TRUSTED_CLIENTSlist to decide whether to inject CSS on its pages (login, OTP, choose-handle, recovery). Seedocs/configuration.mdfor the full reference.For the demo app, a new optional
EPDS_CLIENT_THEMEenv var selects a named theme preset (e.g.ocean) that applies consistent styling to both the demo's own pages and the CSS served in its client metadata. When unset, the demo uses the default light theme with no branding CSS. Seepackages/demo/.env.examplefor details.
-
#77
b3c779aThanks @aspiers! - Generate ES256 keypairs withpnpm jwk:generateinstead of re-running full setup.Affects: Client app developers
Client app developers: A new
pnpm jwk:generatecommand outputs a compact ES256 private JWK (with auto-derivedkid) on stdout. Use this when you need a keypair forprivate_key_jwtclient authentication without running the fullscripts/setup.sh. The output is suitable for theEPDS_CLIENT_PRIVATE_JWKenvironment variable (used by the bundled demo app inpackages/demo, not by third-party client apps) or for embedding the public half in any client metadata'sjwksfield. -
#77
0eaded0Thanks @aspiers! - Updated login integration docs to recommend@atproto/oauth-client-nodeand confidential clients.Affects: Client app developers
Client app developers: The tutorial and skill reference now recommend
@atproto/oauth-client-node'sNodeOAuthClientfor Flow 2 (no hint, handle, or DID input), which handles PAR, PKCE, DPoP, and token exchange automatically. Flow 1 (emaillogin_hint) remains hand-rolled. The default client metadata example has been flipped from"token_endpoint_auth_method": "none"to"private_key_jwt"withjwks_urior inlinejwksfor publishing the public key. A new "Confidential vs public clients" section explains the trade-offs — notably that public clients force a consent screen on every login. New sections cover JWKS key generation, publishing, and rotation.
- Client app developers and Operators:
-
#74
b46273aThanks @aspiers! - The health endpoint now reports the running ePDS version.Affects: Client app developers, Operators
Client app developers: both
/healthendpoints (pds-core and auth-service) now include aversionfield in their JSON response (e.g.{ "status": "ok", "service": "epds", "version": "0.2.2+f37823ee" }). You can use this to check which ePDS release your app is running against. The demo frontend also displays the version in its page footer.Operators: in Docker and Railway deployments the version is automatically set to
<package.json version>+<8-char commit SHA>at build time. In local dev it falls back to the rootpackage.jsonversion (e.g.0.2.2). To override, set theEPDS_VERSIONenvironment variable on both pds-core and auth-service to any string. Docker Compose users should now build withpnpm docker:buildinstead ofdocker compose builddirectly — the wrapper stamps the version before building, and the build will fail if the version stamp is missing.
-
#76
f709066Thanks @aspiers! - The upstream PDS version now appears on the stock health endpoint.Affects: Client app developers, Operators
/xrpc/_healthnow returns the upstream@atproto/pdsversion in its JSON response (e.g.{ "version": "0.4.211" }). Previously this endpoint returned{}. This is independent of the ePDS version reported by/health.Operators: no configuration is needed — the version is read from the installed
@atproto/pdspackage at startup. To override, set thePDS_VERSIONenvironment variable on pds-core.
- Everyone (end users, client app developers, operators):
- Operators also:
-
#21
10287caThanks @aspiers! - The permissions shown on the sign-in consent screen now match what the app actually asked for.Affects: End users, Client app developers, Operators
End users: When you sign in to a third-party app through ePDS and are asked to approve what the app can do with your account, the list you see now reflects the permissions that particular app actually requested. Previously the screen always showed the same hard-coded list ("Read and write posts", "Access your profile", "Manage your follows") no matter which app you were signing in to, which was misleading. The consent screen itself also now looks and behaves like the standard AT Protocol consent screen used elsewhere in the ecosystem.
Client app developers: The consent screen rendered at
/oauth/authorizeis now the stock@atproto/oauth-providerconsent-view.tsx, driven by the realscope/permissionSetsyour client requests. The previous auth-service implementation ignored the requested scopes entirely. After OTP verification and (for new users) account creation,epds-callbacknow binds the device session viaupsertDeviceAccount()and redirects through/oauth/authorize, so the upstreamoauthMiddlewarerunsprovider.authorize()— includingcheckConsentRequired()— against the actual request. Clients that only need scopes the user has already approved will now be auto-approved instead of being shown a redundant consent screen. Support for branding the consent screen is currently being worked on.Operators: No configuration changes are required. Consent state now lives in the upstream provider's
authorizedClientstracking. Theclient_loginstable is no longer used but is left in place (not dropped) to avoid breaking rollbacks in case they were ever needed. -
#21
5110845Thanks @aspiers! - Trusted apps can optionally skip the consent screen when new users sign up.Affects: End users, Client app developers, Operators
End users: When you create a new account through a trusted app, ePDS can now send you straight back to that app without showing a separate consent screen first.
Client app developers: To opt in, your client metadata must include
epds_skip_consent_on_signup: true. The skip only applies on initial sign-up, only for trusted clients, and only when the server is configured to allow it.Operators: This feature has separate configuration from the normal consent-screen changes. To enable it, set
PDS_SIGNUP_ALLOW_CONSENT_SKIP=trueon pds-core. The skip only applies to clients already trusted viaPDS_OAUTH_TRUSTED_CLIENTS(also on pds-core) and only when the client metadata opts in withepds_skip_consent_on_signup: true. -
#65
313c071Thanks @aspiers! - Sign-in no longer fails when the login service and your data server share a domain name.Affects: Operators
Operators: Fix for an unreleased bug introduced by the above consent changes in #21. No configuration changes are needed. This is just a heads-up in case anyone deployed an ePDS from git within a small window; if you notice logins failing on your ePDS, make sure to upgrade to v0.2.2 or newer.
Technical details:
The upstream
@atproto/oauth-providerrejectssec-fetch-site: same-siteonGET /oauth/authorize. This caused a400 Forbidden sec-fetch-site headererror on deployments where the auth service and PDS share a registrable domain (e.g.auth.epds1.test.certified.appandepds1.test.certified.app). Browsers sendsame-siteon the 303 redirect chain from the auth subdomain to the PDS, and the upstream code does not allow it.pds-core now includes middleware that rewrites
sec-fetch-site: same-sitetosame-originonGET /oauth/authorizewhen the request originates from the trusted auth subdomain.Additionally, DB migration v9 (which previously dropped the
client_loginstable) is now a no-op. The table is no longer used but is kept in place to avoid breaking emergency rollbacks to older code that still references it.This bug was missed by the comprehensive E2E test suite due to an unfortunate combination of quirks:
- The upstream ATProto PDS does not support
sec-fetch-site: same-site, marked as a@TODOin the source. Stock ATProto never encounterssame-sitebecause the PDS serves its own login UI on the same origin. - Railway does not allow any control over generated domains for PR preview environments. Each service gets a flat
*.up.railway.appsubdomain, andup.railway.appis on the Public Suffix List — so cross-service requests arecross-site(allowed), neversame-site. This creates a small but ultimately significant difference in DNS topology from Certified infrastructure where all services share a registrable domain. - The deliberate introduction (in PR #21) of a double redirect from
auth-service/auth/completetopds-core/oauth/epds-callbacktopds-core/oauth/authorize, which sends the browser through a cross-origin hop on the same site — the exact pattern the upstream validation rejects.
- The upstream ATProto PDS does not support
-
#51
cfaeabeThanks @aspiers! - Mask every segment of email addresses on recovery and account-login pages, including the domain and TLD (HYPER-259).Affects: End users
Previously, the partially-masked email shown on the recovery and account-login pages left the entire domain visible (e.g.
jo***@gmail.com), making the user's email address much easier to guess for anyone who entered a known handle on the login flow. Each dot-separated segment of both local part and domain is now masked independently, revealing only the final character of each segment (e.g.persons.address@gmail.com→***s.***s@***l.***m). Hiding the domain is important: leaving a common domain visible would make popular providers trivially identifiable, which in turn makes the local part much more guessable.
- End users:
- Client app developers:
- Operators:
- Configurable sign-in code length, optionally mixing letters and numbers.
- Choose your own handle when signing up, instead of being given a random one.
- Fail-fast validation of internal environment variables on the auth service.
- Honour the generic
PORTenvironment variable on both services, so Railway's automatic healthcheck succeeds without per-service configuration.
-
#14 Thanks @Kzoeps! - Configurable sign-in code length, optionally mixing letters and numbers.
Affects: End users, Operators
End users: depending on how the ePDS instance you sign in to is configured, sign-in codes sent to your email may now be shorter (as few as 4 characters) or longer (up to 12 characters) than the previous fixed length of 8, and may include uppercase letters as well as digits. Codes of 8 or more characters are displayed grouped in the email for readability (e.g.
1234 5678), but you can still paste the whole code into the sign-in form as usual — the space is just a visual aid.Operators: two new environment variables on the auth service —
OTP_LENGTH(integer, range 4–12, default 8) andOTP_CHARSET(numeric(default) oralphanumeric;alphanumericuses uppercase A–Z plus 0–9). Values outside the range cause the service to fail on startup. The OTP form fields (input width,pattern,inputmode,autocapitalize) adapt automatically from the configured length and charset; no template changes are required.Operators running custom email templates: the shared email helpers now format OTPs with visual grouping when the code is 8 characters or longer — e.g.
1234 5678in subject lines and plain text, and<span>1234</span><span>5678</span>with CSS spacing in HTML so that copy-paste still yields the flat code. If you render OTPs yourself rather than going throughEmailSender.sendOtpCode(), importformatOtpPlain()andformatOtpHtmlGrouped()from@certified-app/sharedinstead of interpolating the raw code. -
#13 #29 #33 #36 Thanks @Kzoeps & @aspiers! - Choose your own handle when signing up, instead of being given a random one.
Affects: End users, Client app developers, Operators
End users: the signup flow now shows a handle picker by default instead of assigning a random handle. You can type a custom handle and the picker will check availability as you type, or click the random-handle button to take what the old flow would have given you. The picker now accepts handles as short as 5 characters and handles are validated more strictly so that some handles that used to be accepted may now be rejected up-front with a clearer error. The picker layout has been widened to accommodate long PDS domain names without truncation.
Client app developers (building on top of ePDS): a new
epds_handle_modesetting controls which variant of the signup handle picker is shown. Accepted case-sensitive values:picker— always show the picker, no random option offered.random— always assign a random handle, no picker (the pre-0.2.0 behaviour).picker-with-random(default) — show the picker but include a "generate random" option.
The setting is resolved with the following precedence (first match wins), falling back to a built-in default:
epds_handle_modequery parameter on the/oauth/authorizerequest.epds_handle_modefield in the OAuth client metadata JSON served at the client'sclient_idURL.EPDS_DEFAULT_HANDLE_MODEenvironment variable on the auth service.- Built-in default:
picker-with-random.
This precedence was previously wrong — the env var was consulted before the client metadata, so clients could not override a server default. If you relied on that bug, your env var setting will now be overridden by whatever the client metadata says.
To force a specific handle mode for users of your app, add the field to the client metadata JSON that your
client_idURL returns, alongside the standard OAuth fields:{ "client_id": "https://example.com/oauth/client-metadata.json", "client_name": "Example", "redirect_uris": ["https://example.com/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "atproto transition:generic", "token_endpoint_auth_method": "none", "application_type": "web", "dpop_bound_access_tokens": true, "epds_handle_mode": "picker" }Unknown or invalid values are silently ignored and fall through to the next source. If you need to override per-request (e.g. for a specific signup campaign), append
?epds_handle_mode=pickerto your/oauth/authorizeURL.Operators: set
EPDS_DEFAULT_HANDLE_MODEon the auth service to change the default handle-picker variant for clients that don't specify one in their client metadata. Accepted values are the same as those listed in the Client app developers section above (picker,random,picker-with-random). See.env.examplefor documentation.
-
#3 #6 Thanks @aspiers! - Sign in faster from third-party apps that already know who you are.
Affects: End users
When you sign in to a third-party AT Protocol app (anything built on top of the Bluesky account system, for example) that already knows your handle or DID, ePDS now jumps straight to the "enter your sign-in code" step. Previously you would have been asked to retype your email address first, even though the app you were using had already identified you — that extra step is gone.
This fixes two specific situations that didn't work before: apps that identified you by handle or DID rather than email, and apps that sent the identifier over a back channel rather than in the sign-in URL.
-
#20 #23 Thanks @aspiers! - Fail-fast validation of internal environment variables on the auth service.
Affects: Operators
A new
requireInternalEnv()helper runs at auth service startup and reports exactly which required internal variables are missing or malformed, replacing cryptic downstream errors likeTypeError: Failed to parse URLon the first request.Checks performed:
PDS_INTERNAL_URL— must be set and must begin withhttp://orhttps://(matched case-insensitively). Trailing slashes are stripped automatically.EPDS_INTERNAL_SECRET— must be set to any non-empty string.
If you previously set
PDS_INTERNAL_URLto a bare hostname likecore.railway.internalorcore:3000, the service will now refuse to start with this error:PDS_INTERNAL_URL is missing the http:// or https:// scheme: "core.railway.internal"Add the scheme and port explicitly. The canonical Docker Compose default (shown in
.env.example) ishttp://core:3000; for Railway's private networking the equivalent ishttp://<service>.railway.internal:<PDS_PORT>, substituting whichever service name you gave your pds-core deployment and thePDS_PORTyou configured on it. Railway's internal network uses plain HTTP on explicit ports, not HTTPS. This previously "worked" in the sense that the service started, but then failed on the first internal request; the new behaviour surfaces the misconfiguration immediately. -
#27 Thanks @aspiers! - Honour the generic
PORTenvironment variable on both services, so Railway's automatic healthcheck succeeds without per-service configuration.Affects: Operators
New port-resolution precedence (first set value wins):
- auth service:
AUTH_PORT→PORT→3001 - pds-core:
PDS_PORT→PORT→3000(pds-core readsPDS_PORT; whenPDS_PORTis unset,PORTis copied into it before@atproto/pdsreads its environment)
If you run ePDS on Docker Compose or another orchestrator where you set
AUTH_PORT/PDS_PORTexplicitly: no change — your existing settings take precedence overPORT.If you run ePDS on Railway (or any platform that injects
PORTautomatically): you can now remove service-specificAUTH_PORT/PDS_PORToverrides from your Railway variables. Each service will pick up Railway's injectedPORTand healthchecks will bind correctly. Previously these services bound to their hardcoded defaults regardless ofPORT, causing Railway healthchecks to fail. - auth service: