One authentication flow
Dstar events and streams are ordinary HTTP requests. Put the page routes and component dispatcher behind one Phoenix authentication pipeline, and that same gate protects the initial render and every later interaction.
Demo
Browser
Sends the signed session cookie with each request; Datastar actions also carry the CSRF signal.
One Phoenix pipeline
Fetch the session, load the current scope, require a user, and reject before dispatch.
Dstar.Page
Only authenticated conns reach mount, event, stream, or component callbacks.
| Request | Purpose | After the same gate |
|---|---|---|
| GET /settings | Initial HTML | mount/2 → render/1 |
| POST /settings/_event/:event | Browser event | authorize/2 → handle_event/3 |
| POST /settings | Long-lived stream | authorize/2 → handle_connect/2 |
| POST /ds/:module/:event | Shared component event | component handle_event/3 |
How it works
- Every Dstar request crosses the same Phoenix authentication pipeline.
- The signed session cookie is already available because every interaction is HTTP.
- mount/2 runs only on GET; it is not protection for direct event or stream POSTs.
- authorize/2 handles page- or resource-specific checks before SSE starts.
- CSRF protection is separate from authentication and still belongs in the browser pipeline.
A dstar "/settings", SettingsPage
line expands to three routes: a GET, an event POST, and a stream POST. Phoenix runs every
one through the surrounding pipelines. The browser sends the same signed session cookie,
so your existing fetch_current_scope
and require_authenticated_user
plugs are the only authentication path you need. Put
dstar_components/2
in that scope too when its handlers require a user.
# router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_current_scope
plug Dstar.Plugs.RenameCsrfParam
plug :protect_from_forgery
end
pipeline :require_authenticated_user do
plug :require_authenticated_user
end
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
dstar "/settings", SettingsPage
dstar_components "/ds", [SettingsForm]
end
Authentication is not authorization
The pipeline answers “who is this?” once, consistently. A record-level rule still has to
answer “may this user change this account?” Use
authorize/2
for page event and stream POSTs. It runs after signals are parsed but before the response
becomes SSE, so it can still return a normal 401 or 403. On GET, load only an authorized
resource in mount/2. Component handlers must do
their resource check before calling start/1.
# settings_page.ex — resource authorization, not a second login flow
@impl Dstar.Page
def authorize(conn, {:event, "save:" <> account_id}) do
case Accounts.fetch_for_scope(conn.assigns.current_scope, account_id) do
{:ok, account} -> assign(conn, :account, account)
:error ->
conn
|> send_resp(403, "Forbidden")
|> halt()
end
end
def authorize(conn, {:event, _event}), do: conn
def authorize(conn, {:stream, _params}), do: conn
If this were LiveView
LiveView uses the same session token, but it has two request lifecycles to guard. The Plug
protects the initial HTTP request; an on_mount
hook protects the connected LiveView process. Dstar has no authenticated socket process,
so every request simply re-enters the Plug pipeline.
Dstar
# router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_current_scope
plug Dstar.Plugs.RenameCsrfParam
plug :protect_from_forgery
end
pipeline :require_authenticated_user do
plug :require_authenticated_user
end
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
dstar "/settings", SettingsPage
dstar_components "/ds", [SettingsForm]
end
LiveView
# Illustrative generated-style LiveView routing.
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{MyAppWeb.UserAuth, :require_authenticated}] do
live "/settings", SettingsLive, :edit
end
end