docs-middleware.md
Middleware is reusable, server-side logic that runs before a page renders or a command executes. It is a natural home for cross-cutting concerns such as authentication, authorization, request enrichment (locale, theme, tenant, feature flags), rate limiting, or audit logging.
Middleware is server-only. Actions run on the client and cross no trust boundary, so they have no middleware - anything privileged an action needs goes through a command, which middleware gates server-side.
Middleware lives in one of two places: an inline function on the page or component itself, or a module you reuse across pages and components. Inline suits one-off logic specific to a single page or component. A module suits anything shared, and is written as a leaf (does the work itself) or a composite (combines other middleware). The Server helpers (put_status, put_redirect, put_session, put_stash, and the rest) are available unqualified in all of them.
The simplest middleware is a function defined right on the page or component, attached by name with middleware :name. It receives the Server struct and any options it was attached with ([] when none), and returns a Server struct - the same one, or one it has updated, which threads into the next middleware.
defmodule MyApp.DashboardPage do
use Hologram.Page
route "/dashboard"
middleware :put_locale
def put_locale(server, _opts) do
put_stash(server, :locale, get_request_header(server, "accept-language"))
end
end
Options passed in the declaration arrive as the second argument:
middleware :put_theme, default: "dark"
def put_theme(server, opts) do
put_stash(server, :theme, get_cookie(server, "theme") || opts[:default])
end
A leaf is the reusable form: a module that defines call/2, with the same (server, opts) signature as an inline function but in its own module so multiple pages or components can share it.
defmodule MyApp.Middleware.RequireAuth do
use Hologram.Middleware
@impl Hologram.Middleware
def call(server, _opts) do
require_auth(server, get_session(server, :user_id))
end
defp require_auth(server, nil), do: put_redirect(server, MyApp.LoginPage)
defp require_auth(server, _user_id), do: server
end
A composite declares a sub-chain with the middleware macro instead of defining call/2 - the framework generates the call/2 that runs that sub-chain. Because a composite has a call/2 like any other middleware, it attaches anywhere a leaf can - including inside another composite. A terminal response from any member stops the rest, and that short-circuit carries through nested composites.
defmodule MyApp.Middleware.AdminGate do
use Hologram.Middleware
middleware MyApp.Middleware.RequireAuth
middleware MyApp.Middleware.RequireAdmin
end
Attach middleware to a page or component with the middleware macro. The target is either a module or an inline :function name, and each takes optional keyword options:
middleware SomeModule - runs SomeModule.call(server, opts).middleware SomeModule, max: 100 - same, with options passed as the second argument.middleware :some_function - runs the host module's own inline some_function(server, opts).middleware :some_function, role: :admin - same, with options.Attach as many as you like, mixing inline functions and modules. Declarations run top to bottom, in the order written.
Composition is explicit. Middleware composes two ways: a base module that shares its declarations with every page or component that uses it, and a composite that bundles middleware into a single unit (shown above). Both resolve at compile time into one flat chain, with no runtime registry and nothing injected behind your back, so the full chain stays visible where it is attached - good for review.
A base module whose __using__ injects middleware declarations passes them on to every page or component that uses it - the base can wrap use Hologram.Page or use Hologram.Component. The child's own declarations accumulate after the inherited ones (parent first, then child), so a shared set plus a child-specific addition is just one more middleware line.
defmodule MyApp.AdminPage do
defmacro __using__ (_opts) do
quote do
use Hologram.Page
middleware MyApp.Middleware.RequireAuth
layout MyApp.AdminLayout
end
end
end
defmodule MyApp.SettingsPage do
use MyApp.AdminPage
route "/admin/settings"
middleware MyApp.Middleware.RequireAdmin
# ...
end
App-wide concerns are assembled per page or component - a shared base module or composite plugged into the ones that need it. This avoids the anti-pattern of a global gate wrongly applying to public pages (login, signup) that need no auth. Truly-universal concerns that apply to every request - CSRF protection and telemetry, for example - are handled at the framework level, not via your middleware.
Where middleware runs depends on where it is attached:
init/3) and before any of that page's commands.Component middleware lets a distributable component gate its own commands regardless of where it is embedded.
Security - a page's middleware does not cover its components' commands. Dispatch is flat. It runs only the target module's middleware, never a tree of them. So when a command targets a component - even one nested deep inside a page you have gated - that component's middleware runs, but the page's and any ancestor component's do not. There is no inheritance up the nesting. To protect a component's commands, attach the gate to the component itself, directly or through a shared composite the page and component both use. Do not rely on the page's gate to cover them.
Ordering note: anything that must run even on a rejected request (audit logging especially) goes early in the chain, since a terminal response stops everything after it.
Middleware operates on a Server struct - the same struct init/3 and command/3 receive. It has three zones:
method, scheme, host, port, path, query, raw_query, and ip.status, headers, and body.init/3 or command/3.Identity, session, and cookies live here too. The functions for reading the request and writing each zone are in the Helper reference below. Enrichment is a common middleware job - the example here resolves the current user once and stashes it, so everything downstream can read it:
defmodule MyApp.Middleware.LoadCurrentUser do
use Hologram.Middleware
@impl Hologram.Middleware
def call(server, _opts) do
load_user(server, get_session(server, :user_id))
end
defp load_user(server, nil), do: server
defp load_user(server, user_id) do
put_stash(server, :current_user, Accounts.get_user(user_id))
end
end
Middleware stops the request by producing a response - that is, by setting a status on the server. put_status sets that field rather than halting on the spot - the middleware runs to completion, and once it returns with a status set, the rest of the chain and the handler are skipped, and the response is built from the status, headers, and body the chain accumulated. Decorations such as cookies and headers set before the stop still apply, so denying and setting a cookie both land.
There is no separate halt - the status is the signal, set as an integer status code or an atom alias. A redirect sets a status, so it is inherently terminal:
put_redirect(server, MyApp.LoginPage) - stops and redirects (302 by default).put_status(server, 403) - stops with a Forbidden response.put_status(server, :not_found) - stops and renders the app's registered 404 page.defmodule MyApp.Middleware.RequireAdmin do
use Hologram.Middleware
@impl Hologram.Middleware
def call(server, _opts) do
check_role(server, get_session(server, :role))
end
defp check_role(server, :admin), do: server
defp check_role(server, _role), do: put_status(server, :forbidden)
end
The request fields reflect what the client claimed or what an upstream proxy resolved - they are not authenticated.
host is client-supplied and spoofable , even with no proxy in front. Validate it against an allowlist before using it for a security decision (for example, tenant routing). Never build security-sensitive absolute URLs - password-reset links, emails - from host or request_url/1. Use a configured canonical URL instead.ip / scheme resolution is an ingress concern. In embedded mode (Hologram inside your Phoenix endpoint), add RemoteIp and/or Plug.RewriteOn to your endpoint to resolve the real client ip / scheme behind a proxy. Hologram reads the already-resolved conn and does no resolution of its own, so if you do not configure these, ip is the proxy's address. In standalone mode , Hologram resolves ip / scheme / port out of the box (trusted-proxy-gated when behind a proxy), while still requiring you to validate host.The three keyed stores differ in key type, by lifetime:
These helpers are imported into pages, components, and use Hologram.Middleware modules, so you call them unqualified. The get_* readers take an optional final default, returned when the value is absent (otherwise nil).
get_request_header(server, name) - reads a request header by case-insensitive name.referrer_url(server) - the referring URL, or nil.request_url(server) - the full request URL assembled from the request fields. Client-claimed, so never use it for security-sensitive links (see Proxy and trust model).append_response_header(server, name, value) - appends a value to the response header name, keeping that header's existing value (comma-separated) instead of replacing it like put_response_header.delete_response_header(server, name) - removes a response header.get_response_header(server, name) - reads a header already set on the response.put_redirect(server, url_or_page) - a 302 redirect to either a URL string or a page module.put_redirect(server, page, params) - a 302 redirect to a page module with route params.put_response_body(server, body) - sets the response body (iodata). Not terminal on its own - pair it with put_status.put_response_header(server, name, value) - sets a response header (name lower-cased), replacing any existing value. Raises on cookie / set-cookie - use the cookie helpers for those.put_status(server, status) - sets the response status and stops the chain. status is an integer like 403 or an atom alias like :forbidden or :not_found. An unknown alias or out-of-range integer raises. The full alias set is in Status aliases below.delete_user_id(server) - clears the authenticated user.put_user_id(server, user_id) - records the authenticated user (a string, integer, or atom), persisted to the session.Available here too, documented in full on their own pages:
get_session / put_session / delete_session. See Session.get_cookie / put_cookie / delete_cookie. See Cookies.delete_stash(server, key) - removes a stashed value.get_stash(server, key) - reads a stashed value.put_stash(server, key, value) - stashes a value for downstream middleware and handlers.Each status code and the atom alias put_status accepts for it. An unknown alias raises.
| Code | Alias |
|---|---|
| 100 | :continue |
| 101 | :switching_protocols |
| 102 | :processing |
| 103 | :early_hints |
| 200 | :ok |
| 201 | :created |
| 202 | :accepted |
| 203 | :non_authoritative_information |
| 204 | :no_content |
| 205 | :reset_content |
| 206 | :partial_content |
| 207 | :multi_status |
| 208 | :already_reported |
| 226 | :im_used |
| 300 | :multiple_choices |
| 301 | :moved_permanently |
| 302 | :found |
| 303 | :see_other |
| 304 | :not_modified |
| 305 | :use_proxy |
| 307 | :temporary_redirect |
| 308 | :permanent_redirect |
| 400 | :bad_request |
| 401 | :unauthorized |
| 402 | :payment_required |
| 403 | :forbidden |
| 404 | :not_found |
| 405 | :method_not_allowed |
| 406 | :not_acceptable |
| 407 | :proxy_authentication_required |
| 408 | :request_timeout |
| 409 | :conflict |
| 410 | :gone |
| 411 | :length_required |
| 412 | :precondition_failed |
| 413 | :payload_too_large |
| 414 | :uri_too_long |
| 415 | :unsupported_media_type |
| 416 | :range_not_satisfiable |
| 417 | :expectation_failed |
| 418 | :im_a_teapot |
| 421 | :misdirected_request |
| 422 | :unprocessable_entity |
| 423 | :locked |
| 424 | :failed_dependency |
| 425 | :too_early |
| 426 | :upgrade_required |
| 428 | :precondition_required |
| 429 | :too_many_requests |
| 431 | :request_header_fields_too_large |
| 451 | :unavailable_for_legal_reasons |
| 500 | :internal_server_error |
| 501 | :not_implemented |
| 502 | :bad_gateway |
| 503 | :service_unavailable |
| 504 | :gateway_timeout |
| 505 | :http_version_not_supported |
| 506 | :variant_also_negotiates |
| 507 | :insufficient_storage |
| 508 | :loop_detected |
| 510 | :not_extended |
| 511 | :network_authentication_required |
Sponsored by
Previous
Next