Definition

Plug is a specification and library for composable modules that process web requests in the Elixir ecosystem. At its core, Plug defines a simple contract: a plug receives a connection struct (%Plug.Conn{}), transforms it in some way, and returns the modified connection. This minimal interface -- receive a connection, return a connection -- enables plugs to compose into pipelines where each plug performs a single, focused transformation: parsing request bodies, authenticating users, enforcing rate limits, setting response headers, or any other request/response manipulation. The composability guarantee comes from the invariant that every plug's input type equals its output type: Plug.Conn.t() -> Plug.Conn.t(). This algebraic closure property means that any sequence of plugs is itself a valid plug, enabling arbitrary nesting and reuse.

Plug serves as the foundation for all HTTP processing in the Elixir ecosystem. Phoenix is built entirely on top of Plug -- every Phoenix endpoint, router, pipeline, controller action, and LiveView mount passes through a chain of plugs. Understanding Plug is therefore essential for understanding how HTTP requests are processed in any Phoenix application, including the Prismatic Platform's web dashboard and API gateway. The relationship between Plug and Phoenix is analogous to Rack and Ruby on Rails, WSGI and Django, or Ring and Compojure -- Plug provides the HTTP abstraction layer, and Phoenix provides the application framework built on that abstraction.

The library provides two plug types: function plugs (simple functions that accept a connection and options) and module plugs (modules implementing init/1 and call/2 callbacks). Function plugs are convenient for simple transformations defined inline within routers or controllers, while module plugs support compile-time initialization for performance-critical operations like parsing configuration, compiling regex patterns, or pre-computing values that would be expensive to recompute per-request. The init/1 callback runs once at compile time, and its return value is passed as the second argument to call/2 on every request, amortizing initialization cost across all requests.

The Plug.Conn Struct

Plug.Conn is the central data structure in the Plug ecosystem. It represents the entire state of an HTTP connection -- request data, response data, and metadata -- as an immutable Elixir struct. Because Elixir data structures are immutable, each plug transformation creates a new Plug.Conn struct with the modified fields while sharing unchanged data through structural sharing, making the pipeline both safe and memory-efficient:

%Plug.Conn{
  # Request fields (populated from the HTTP request)
  host: "prismatic-prod.fly.dev",
  method: "POST",
  path_info: ["api", "v1", "perimeter", "discover"],
  query_string: "",
  req_headers: [{"content-type", "application/json"}, {"authorization", "Bearer ..."}],
  body_params: %{"domain" => "example.com"},
  remote_ip: {198, 51, 100, 42},

  # Response fields (set by plugs during processing)
  status: nil,
  resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}],
  resp_body: nil,

  # Connection state machine
  state: :unset,          # :unset -> :set -> :sent -> :file | :chunked
  halted: false,          # true if a plug has halted the pipeline

  # Assigns (application-specific data passed between plugs)
  assigns: %{
    current_user: nil,
    request_id: "F3nB8kQ..."
  },

  # Private (framework-internal data)
  private: %{
    phoenix_router: PrismaticWeb.Router,
    phoenix_endpoint: PrismaticWeb.Endpoint,
    phoenix_action: :index
  }
}
Field CategoryKey FieldsDescription
Requestmethod, path_info, req_headers, body_params, query_params, remote_ipParsed from the incoming HTTP request by the adapter
Responsestatus, resp_headers, resp_bodySet progressively by plugs during processing
Statestate, haltedTracks connection lifecycle and pipeline flow control
AssignsassignsApplication-specific key-value store for passing data between plugs
PrivateprivateFramework-internal metadata (router, endpoint, action, etc.)
Cookiescookies, req_cookies, resp_cookiesCookie parsing and setting
Sessionsecret_key_baseSession encryption key for signed/encrypted cookies

The assigns field is particularly important -- it serves as the primary mechanism for passing data between plugs in a pipeline. An authentication plug sets assigns.current_user, which a subsequent authorization plug reads to make access control decisions. This pattern avoids global state and makes data flow explicit: if a plug needs data, it must be in assigns or the plug cannot function, making dependencies between plugs visible and testable.

Function Plugs

Function plugs are the simplest form: a function that accepts a connection and options, and returns a connection. They are typically defined as private functions within a router or controller module:

defmodule PrismaticWeb.Router do
  @moduledoc """
  Main router for the Prismatic Platform web dashboard.
  Defines plug pipelines and route scoping.
  """

  use PrismaticWeb, :router

  # Function plug defined inline in a pipeline
  pipeline :api do
    plug :accepts, ["json"]
    plug :put_request_id
    plug :log_request
    plug :set_content_type
  end

  # Function plug implementation - adds unique request ID
  defp put_request_id(conn, _opts) do
    request_id = Ecto.UUID.generate()

    conn
    |> Plug.Conn.assign(:request_id, request_id)
    |> Plug.Conn.put_resp_header("x-request-id", request_id)
  end

  # Function plug implementation - structured request logging
  defp log_request(conn, _opts) do
    Logger.info("#{conn.method} #{conn.request_path}",
      request_id: conn.assigns[:request_id],
      remote_ip: conn.remote_ip |> :inet.ntoa() |> to_string(),
      user_agent: Plug.Conn.get_req_header(conn, "user-agent") |> List.first()
    )
    conn
  end

  # Function plug implementation - set JSON content type
  defp set_content_type(conn, _opts) do
    Plug.Conn.put_resp_content_type(conn, "application/json")
  end
end

Function plugs are ideal when the transformation is simple, self-contained, and does not require compile-time initialization. Their simplicity makes them easy to read inline within router definitions, keeping the plug pipeline declaration close to the transformation logic.

Module Plugs

Module plugs implement the Plug behaviour with two callbacks: init/1 (called at compile time for initialization) and call/2 (called at runtime for each request). The separation of initialization from execution is a critical performance optimization:

defmodule PrismaticWeb.Plugs.APIAuth do
  @moduledoc """
  Authenticates API requests via Bearer tokens.
  Supports token verification, path exclusion, and
  structured error responses for unauthorized access.
  """

  @behaviour Plug

  import Plug.Conn

  @type opts :: %{
    token_prefix: String.t(),
    excluded_paths: list(String.t()),
    realm: String.t()
  }

  @impl true
  @spec init(keyword()) :: opts()
  def init(opts) do
    # Called once at compile time -- expensive operations go here
    %{
      token_prefix: Keyword.get(opts, :token_prefix, "Bearer"),
      excluded_paths: Keyword.get(opts, :excluded_paths, ["/api/v1/health"]),
      realm: Keyword.get(opts, :realm, "prismatic-api")
    }
  end

  @impl true
  @spec call(Plug.Conn.t(), opts()) :: Plug.Conn.t()
  def call(conn, opts) do
    if conn.request_path in opts.excluded_paths do
      conn
    else
      case get_req_header(conn, "authorization") do
        [header] -> authenticate(conn, header, opts)
        _ -> unauthorized(conn, opts)
      end
    end
  end

  defp authenticate(conn, header, opts) do
    case String.split(header, " ", parts: 2) do
      [prefix, token] when prefix == opts.token_prefix ->
        case PrismaticAuth.verify_token(token) do
          {:ok, user} ->
            conn
            |> assign(:current_user, user)
            |> assign(:authenticated_at, DateTime.utc_now())

          {:error, :expired} ->
            unauthorized(conn, opts, "Token expired")

          {:error, _reason} ->
            unauthorized(conn, opts, "Invalid token")
        end

      _ ->
        unauthorized(conn, opts, "Malformed authorization header")
    end
  end

  defp unauthorized(conn, opts, message \\ "Authentication required") do
    conn
    |> put_resp_header("www-authenticate", "Bearer realm=\"#{opts.realm}\"")
    |> put_status(:unauthorized)
    |> Phoenix.Controller.json(%{error: message, status: 401})
    |> halt()
  end
end

The halt/1 function is critical for security plugs. When a plug calls halt/1, it sets conn.halted = true, which signals Phoenix to stop executing subsequent plugs in the pipeline. This prevents unauthenticated requests from reaching controllers. Without halt/1, the response would be set but the pipeline would continue, potentially overwriting the 401 response with controller output.

Pipeline Composition

Phoenix routers organize plugs into named pipelines that are applied to groups of routes. Pipelines compose -- a scope can pipe_through multiple pipelines, which are concatenated in order:

defmodule PrismaticWeb.Router do
  @moduledoc """
  Router with composable plug pipelines for browser,
  API, and admin route groups.
  """

  use PrismaticWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {PrismaticWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug PrismaticWeb.Plugs.SetCurrentUser
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug PrismaticWeb.Plugs.APIAuth
    plug PrismaticWeb.Plugs.RateLimiter, max_requests: 100, window_ms: 60_000
    plug PrismaticWeb.Plugs.RequestLogger
    plug PrismaticWeb.Plugs.CORSHeaders
  end

  pipeline :admin do
    plug PrismaticWeb.Plugs.RequireAdmin
    plug PrismaticWeb.Plugs.AuditLogger
  end

  pipeline :telemetry do
    plug PrismaticWeb.Plugs.TelemetryPlug
  end

  # Routes inherit ALL plugs from pipe_through
  scope "/", PrismaticWeb do
    pipe_through [:browser, :telemetry]
    live "/", DashboardLive
    live "/perimeter", PerimeterLive
    live "/osint/toolbox", OsintToolboxLive
  end

  scope "/admin", PrismaticWeb do
    pipe_through [:browser, :admin, :telemetry]  # browser + admin + telemetry
    live "/agents", AdminAgentsLive
    live "/quality", AdminQualityLive
  end

  scope "/api/v1", PrismaticAPI do
    pipe_through [:api, :telemetry]
    get "/health", HealthController, :check
    get "/endpoints", EndpointsController, :index
    post "/:app/:action", DispatchController, :dispatch
  end
end

This enables reusable middleware groups that can be mixed and matched for different route categories. The :telemetry pipeline can be added to any scope to enable request metrics emission, independent of authentication or content type concerns.

Plug.Router for Standalone Applications

While most Elixir web applications use Phoenix, Plug.Router provides a lightweight alternative for simple HTTP services that do not need Phoenix's full feature set:

defmodule PrismaticHealth.Router do
  @moduledoc """
  Lightweight health check router using Plug.Router.
  No Phoenix dependency - suitable for sidecar health endpoints.
  """

  use Plug.Router

  plug Plug.Logger
  plug Plug.Parsers, parsers: [:json], json_decoder: Jason
  plug :match
  plug :dispatch

  get "/health" do
    body = Jason.encode!(%{
      status: "healthy",
      timestamp: DateTime.utc_now(),
      version: Application.spec(:prismatic, :vsn) |> to_string()
    })

    send_resp(conn, 200, body)
  end

  get "/ready" do
    case check_readiness() do
      {:ok, details} ->
        send_resp(conn, 200, Jason.encode!(%{status: "ready", details: details}))

      {:error, reason} ->
        send_resp(conn, 503, Jason.encode!(%{status: "not_ready", reason: reason}))
    end
  end

  match _ do
    send_resp(conn, 404, Jason.encode!(%{error: "not found"}))
  end

  defp check_readiness do
    with {:ok, _} <- Ecto.Adapters.SQL.query(PrismaticStorage.Repo, "SELECT 1"),
         true <- Process.whereis(PrismaticAgents.Supervisor) != nil do
      {:ok, %{database: "connected", agents: "running"}}
    else
      _ -> {:error, "dependencies not ready"}
    end
  end
end

Plug Adapters and HTTP Servers

Plug connects to HTTP servers through adapter modules. The adapter translates between the HTTP server's native connection representation and Plug.Conn:

AdapterHTTP ServerConcurrencyUse Case
Plug.CowboyCowboy 2.xOne Erlang process per connectionProduction standard (Phoenix < 1.7)
BanditBanditOne Erlang process per connection, HTTP/2 nativeModern alternative, pure Elixir (Phoenix >= 1.7)
Plug.TestNone (in-memory)SynchronousTesting without network I/O

Phoenix 1.7+ defaults to Bandit, a pure-Elixir HTTP server with native HTTP/2 support and better performance characteristics. The adapter abstraction means that switching HTTP servers requires only changing the adapter configuration, with zero changes to plug pipelines or application code.

Implementation in Prismatic Platform

The Prismatic Platform uses Plug extensively across its Phoenix applications. Custom plugs enforce platform-specific concerns across both the web dashboard and API gateway:

PlugApplicationPurposeType
APIAuthprismatic_apiBearer token authentication for REST APIModule
RateLimiterprismatic_api, prismatic_webToken bucket rate limiting per IP/userModule
RequestLoggerprismatic_apiStructured logging with request metadataModule
RequireAdminprismatic_webRBAC authorization for admin routesModule
AuditLoggerprismatic_webImmutable audit trail for administrative actionsModule
CORSHeadersprismatic_apiCross-origin resource sharing headersModule
OpenAPIValidatorprismatic_apiOpenAPI spec request/response validationModule
TelemetryPlugprismatic_web, prismatic_apiRequest metrics emission via :telemetryModule
SetCurrentUserprismatic_webSession-based user assignment for browser routesModule

The API gateway (prismatic_api) chains plugs for authentication, RBAC authorization, request validation, rate limiting, and OpenAPI spec enforcement. The web dashboard (prismatic_web) uses plug pipelines for session management, CSRF protection, LiveView setup, and telemetry instrumentation. This separation ensures that API and browser concerns never leak into each other while sharing common infrastructure like telemetry.

Plug Security Patterns

Security-critical plugs follow specific patterns to prevent common vulnerabilities:

defmodule PrismaticWeb.Plugs.RateLimiter do
  @moduledoc """
  Token bucket rate limiter implemented as a Plug.
  Uses ETS for O(1) lookup and atomic counter updates.
  """

  @behaviour Plug

  @impl true
  @spec init(keyword()) :: map()
  def init(opts) do
    table = :ets.new(:rate_limiter, [:set, :public, read_concurrency: true])

    %{
      table: table,
      max_requests: Keyword.get(opts, :max_requests, 100),
      window_ms: Keyword.get(opts, :window_ms, 60_000)
    }
  end

  @impl true
  @spec call(Plug.Conn.t(), map()) :: Plug.Conn.t()
  def call(conn, opts) do
    key = rate_limit_key(conn)
    now = System.monotonic_time(:millisecond)

    case check_rate(opts.table, key, now, opts) do
      {:ok, remaining} ->
        conn
        |> Plug.Conn.put_resp_header("x-ratelimit-limit", to_string(opts.max_requests))
        |> Plug.Conn.put_resp_header("x-ratelimit-remaining", to_string(remaining))

      {:error, :rate_limited, retry_after} ->
        conn
        |> Plug.Conn.put_resp_header("retry-after", to_string(retry_after))
        |> Plug.Conn.put_status(429)
        |> Phoenix.Controller.json(%{error: "Rate limit exceeded"})
        |> Plug.Conn.halt()
    end
  end

  defp rate_limit_key(conn) do
    ip = conn.remote_ip |> :inet.ntoa() |> to_string()
    user_id = conn.assigns[:current_user] && conn.assigns.current_user.id

    user_id || ip
  end
end

Testing Plugs

Plugs are straightforward to test because they are pure functions over the Plug.Conn struct. The Plug.Test module provides helper functions for constructing test connections without starting an HTTP server:

defmodule PrismaticWeb.Plugs.APIAuthTest do
  @moduledoc """
  Tests for the APIAuth plug covering authentication,
  rejection, and path exclusion scenarios.
  """

  use ExUnit.Case, async: true
  use Plug.Test

  alias PrismaticWeb.Plugs.APIAuth

  @opts APIAuth.init([])

  describe "call/2" do
    test "authenticates valid Bearer token" do
      conn =
        conn(:get, "/api/v1/endpoints")
        |> put_req_header("authorization", "Bearer valid_token")
        |> APIAuth.call(@opts)

      refute conn.halted
      assert conn.assigns.current_user != nil
      assert %DateTime{} = conn.assigns.authenticated_at
    end

    test "rejects missing authorization header" do
      conn =
        conn(:get, "/api/v1/endpoints")
        |> APIAuth.call(@opts)

      assert conn.halted
      assert conn.status == 401
      assert {"www-authenticate", _} = List.keyfind(conn.resp_headers, "www-authenticate", 0)
    end

    test "rejects expired tokens with specific message" do
      conn =
        conn(:get, "/api/v1/endpoints")
        |> put_req_header("authorization", "Bearer expired_token")
        |> APIAuth.call(@opts)

      assert conn.halted
      assert conn.status == 401
      body = Jason.decode!(conn.resp_body)
      assert body["error"] =~ "expired"
    end

    test "skips excluded paths" do
      conn =
        conn(:get, "/api/v1/health")
        |> APIAuth.call(APIAuth.init(excluded_paths: ["/api/v1/health"]))

      refute conn.halted
    end

    test "handles malformed authorization header" do
      conn =
        conn(:get, "/api/v1/endpoints")
        |> put_req_header("authorization", "malformed")
        |> APIAuth.call(@opts)

      assert conn.halted
      assert conn.status == 401
    end
  end
end

The key testing insight is that Plug.Test.conn/2 creates a %Plug.Conn{} struct in memory without any network I/O, enabling fast and isolated unit tests. Combined with async: true, plug tests run in parallel and complete in milliseconds.

Plug vs. Middleware in Other Ecosystems

ConceptPlug (Elixir)Rack (Ruby)WSGI (Python)Ring (Clojure)Express (Node.js)
AbstractionStruct transformArray responseCallableMap transformMutation + next()
ImmutabilityYes (new conn)NoNoYes (new map)No (mutates req/res)
Init/Call SplitYes (compile-time init)NoNoNoNo
Halt Mechanismhalt/1 sets flagRack middleware decidesMiddleware decidesShort-circuit returnSkip next() call
CompositionPipeline macroRack::BuilderMiddleware stackThreading macroapp.use() chain
Type SafetyPlug.Conn structArray conventionDict conventionMap conventionNone

Plug's compile-time init/1 phase is unique among these frameworks and provides measurable performance benefits for plugs that require configuration parsing, regex compilation, or other initialization work.

  • Phoenix - Web framework built entirely on Plug
  • Endpoint - Phoenix entry point implemented as a plug pipeline
  • OpenAPI - API specification enforced through validation plugs
  • RBAC - Access control implemented via authorization plugs
  • Rate Limiting - Throughput control via rate limiter plugs
  • LiveView - Server-rendered UI routed through plug pipelines
  • REST API - API endpoints protected by plug chains
  • Structured Logging - Request logging implemented as plugs
  • Metrics - Telemetry emission from plug instrumentation
  • BEAM - VM executing plug pipelines as lightweight processes
  • API Gateway - Gateway pattern implemented with plug pipelines
  • ETS - In-memory storage used by rate limiter and cache plugs

See Also

  • Architecture - Platform architecture
  • Technologies - Technology stack
  • JWT - Token format used in authentication plugs
  • TLS - Transport layer security for plug connections

Connect & Contribute

Created by Tomáš Korcak (korczis) | Open Source under GHL