Raised from a security review of a real PhoenixKit deployment on 2026-08-07. Nothing here is exploitable in that particular install today — the operator had already moved sending to a configured integration, which is exactly the mitigation this issue proposes making a first-class option. It is filed because the default shape is unsafe and the framework already has most of the pieces to say so.
The shape
mix phx.new generates, in dev.exs, a mailer on Swoosh.Adapters.Local, and in the router:
if Application.compile_env(:my_app, :dev_routes) do
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
No authentication, by design — it is a developer convenience. PhoenixKit then delivers password-reset, magic-link, email-confirmation, email-change and invitation mail through that mailer. Those messages carry single-use tokens that exist nowhere else: core stores only a SHA-256 hash, so the raw token lives exclusively in the message.
The consequence is a three-request account takeover for anyone who can reach the dev server:
POST /users/reset-password with any address — the page is public and rate-limited per address, which does not impede one targeted reset.
- The mail is stored in the in-memory mailbox instead of being sent.
GET /dev/mailbox — anonymous — read the link, follow it, set a password.
Picking the Owner yields the whole application. POST /dev/mailbox/clear then removes the evidence.
"It's only dev" is doing a lot of work in that sentence. Dev servers routinely bind 0.0.0.0, sit on a shared container network, and answer on a real hostname behind a proxy — and they hold real data far more often than anyone plans.
Two gaps in what PhoenixKit already has
1. PhoenixKit.Config.mailer_local?/0 misses the common wiring.
def mailer_local? do
case get(PhoenixKit.Mailer, nil)[:adapter] do
Swoosh.Adapters.Local -> true
_ -> false
end
end
It inspects config :phoenix_kit, PhoenixKit.Mailer only. In delegation mode — config :phoenix_kit, mailer: MyApp.Mailer, which PhoenixKit.Mailer's own moduledoc documents and which host apps generally use — the host's adapter is invisible to it. So on a host that delegates to a Swoosh.Adapters.Local mailer, mailer_local?/0 returns false while every message really does land in the local mailbox. I confirmed this on a live install: Application.get_env(:phoenix_kit, PhoenixKit.Mailer) is nil, the host mailer's adapter is Swoosh.Adapters.Local, and the helper answers false.
It also does not account for default_email_integration_uuid, which takes precedence over both mailers in deliver_email/2.
2. When it does fire, the response points people at the mailbox.
PhoenixKitWeb.Components.Core.DevNotice renders, on the public login / registration / forgot-password / magic-link pages:
Development mode: Check mailbox for reset emails
That is a helpful signpost for a developer and a map for everyone else — rendered to anonymous visitors, on the exact pages used to trigger the mail. What is missing is any signal aimed at the operator that the inbox has no authentication.
Proposal
-
Make mailer_local?/0 reflect what will actually send. Resolve it the way deliver_email/2 does — default send integration, then the delegated host mailer, then the built-in one — so delegation and the integration override are both accounted for. This is a small fix and everything below depends on it.
-
Warn at install and doctor time. mix phoenix_kit.install and mix phoenix_kit.doctor can see the resolved adapter, and can scan the host router's routes for Plug.Swoosh.MailboxPreview (Phoenix.Router.routes/1) and check whether its pipeline carries any authentication. When mail is local and the preview is unauthenticated, say so in one sentence with the consequence spelled out — "password-reset links for every account are readable at /dev/mailbox by anyone who can reach this server" — rather than a generic note.
-
Offer the integration send path as an install option. PhoenixKit already ships the Integrations system with SMTP / SES / Brevo providers and default_email_integration_uuid; a dev install pointed at a local catch-all SMTP (Mailpit, Mailhog, smtp4dev) gets the same convenience with none of the tokens-in-a-public-web-page property. Offering this during phoenix_kit.install — "how should development mail be delivered?" — puts the decision in front of the operator once, at the moment they can act on it.
-
Reconsider the DevNotice link. Keeping the notice while dropping the anchor, or rendering the link only when the preview route is behind a pipeline, would stop the framework advertising an unauthenticated inbox on its own public pages.
Happy to open a PR for (1) and (2) if the direction is agreeable — (3) is a larger design question about what phoenix_kit.install should ask.
Raised from a security review of a real PhoenixKit deployment on 2026-08-07. Nothing here is exploitable in that particular install today — the operator had already moved sending to a configured integration, which is exactly the mitigation this issue proposes making a first-class option. It is filed because the default shape is unsafe and the framework already has most of the pieces to say so.
The shape
mix phx.newgenerates, indev.exs, a mailer onSwoosh.Adapters.Local, and in the router:No authentication, by design — it is a developer convenience. PhoenixKit then delivers password-reset, magic-link, email-confirmation, email-change and invitation mail through that mailer. Those messages carry single-use tokens that exist nowhere else: core stores only a SHA-256 hash, so the raw token lives exclusively in the message.
The consequence is a three-request account takeover for anyone who can reach the dev server:
POST /users/reset-passwordwith any address — the page is public and rate-limited per address, which does not impede one targeted reset.GET /dev/mailbox— anonymous — read the link, follow it, set a password.Picking the Owner yields the whole application.
POST /dev/mailbox/clearthen removes the evidence."It's only dev" is doing a lot of work in that sentence. Dev servers routinely bind
0.0.0.0, sit on a shared container network, and answer on a real hostname behind a proxy — and they hold real data far more often than anyone plans.Two gaps in what PhoenixKit already has
1.
PhoenixKit.Config.mailer_local?/0misses the common wiring.It inspects
config :phoenix_kit, PhoenixKit.Maileronly. In delegation mode —config :phoenix_kit, mailer: MyApp.Mailer, whichPhoenixKit.Mailer's own moduledoc documents and which host apps generally use — the host's adapter is invisible to it. So on a host that delegates to aSwoosh.Adapters.Localmailer,mailer_local?/0returnsfalsewhile every message really does land in the local mailbox. I confirmed this on a live install:Application.get_env(:phoenix_kit, PhoenixKit.Mailer)isnil, the host mailer's adapter isSwoosh.Adapters.Local, and the helper answersfalse.It also does not account for
default_email_integration_uuid, which takes precedence over both mailers indeliver_email/2.2. When it does fire, the response points people at the mailbox.
PhoenixKitWeb.Components.Core.DevNoticerenders, on the public login / registration / forgot-password / magic-link pages:That is a helpful signpost for a developer and a map for everyone else — rendered to anonymous visitors, on the exact pages used to trigger the mail. What is missing is any signal aimed at the operator that the inbox has no authentication.
Proposal
Make
mailer_local?/0reflect what will actually send. Resolve it the waydeliver_email/2does — default send integration, then the delegated host mailer, then the built-in one — so delegation and the integration override are both accounted for. This is a small fix and everything below depends on it.Warn at install and doctor time.
mix phoenix_kit.installandmix phoenix_kit.doctorcan see the resolved adapter, and can scan the host router's routes forPlug.Swoosh.MailboxPreview(Phoenix.Router.routes/1) and check whether its pipeline carries any authentication. When mail is local and the preview is unauthenticated, say so in one sentence with the consequence spelled out — "password-reset links for every account are readable at /dev/mailbox by anyone who can reach this server" — rather than a generic note.Offer the integration send path as an install option. PhoenixKit already ships the Integrations system with SMTP / SES / Brevo providers and
default_email_integration_uuid; a dev install pointed at a local catch-all SMTP (Mailpit, Mailhog,smtp4dev) gets the same convenience with none of the tokens-in-a-public-web-page property. Offering this duringphoenix_kit.install— "how should development mail be delivered?" — puts the decision in front of the operator once, at the moment they can act on it.Reconsider the DevNotice link. Keeping the notice while dropping the anchor, or rendering the link only when the preview route is behind a pipeline, would stop the framework advertising an unauthenticated inbox on its own public pages.
Happy to open a PR for (1) and (2) if the direction is agreeable — (3) is a larger design question about what
phoenix_kit.installshould ask.