411 words, 3 min read

Sometimes you want to enforce extra validation right before a response leaves your Phoenix application. A common scenario in multi-tenant applications is making sure that responses only contain data belonging to the current tenant. If an endpoint accidentally leaks data for multiple tenants, you want to stop that response before it ever reaches the browser.

Phoenix gives us a way to hook into the response lifecycle using register_before_send/2. This makes it possible to inspect or even replace the response just before it is delivered.

Let’s create a plug that inspects JSON responses and checks whether they contain the correct tenant ID. We’ll look for the tenant ID in the request parameters, decode the response body, and validate that the response only contains data for the given tenant.

defmodule MyAppWeb.Plugs.TenantResponseValidator do
import Plug.Conn
alias Phoenix.Controller
def init(opts), do: opts
def call(conn, _opts) do
tenant_id = conn.params["tenant_id"]
register_before_send(conn, fn conn ->
case get_resp_header(conn, "content-type") do
[<<"application/json", _rest::binary>>] ->
validate_response(conn, tenant_id)
_ ->
conn
end
end)
end
defp validate_response(conn, tenant_id) do
case Jason.decode(conn.resp_body) do
{:ok, data} ->
if valid_tenant_data?(data, tenant_id) do
conn
else
conn
|> Controller.put_view(MyAppWeb.ErrorJSON)
|> Controller.render(:forbidden, message: "Invalid tenant data")
|> put_status(:forbidden)
end
_ ->
conn
end
end
defp valid_tenant_data?(data, tenant_id) do
tenant_ids =
data
|> extract_tenant_ids()
|> Enum.uniq()
case tenant_ids do
[^tenant_id] -> true
_ -> false
end
end
defp extract_tenant_ids(data) when is_map(data) do
data
|> Map.values()
|> Enum.flat_map(&extract_tenant_ids/1)
end
defp extract_tenant_ids(data) when is_list(data) do
Enum.flat_map(data, &extract_tenant_ids/1)
end
defp extract_tenant_ids(id) when is_binary(id), do: [id]
defp extract_tenant_ids(_), do: []
end

This plug does three things:

  1. Registers a before_send callback.
  2. Only inspects responses with application/json content type.
  3. Decodes the response body and validates the tenant IDs inside. If the check fails, it replaces the response with a 403 Forbidden error.

Add the plug to your API pipeline in router.ex:

pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.TenantResponseValidator
end

Now every JSON response in this pipeline will be checked against the tenant_id in the URL.

This is important for several reasons:

  • Defense in depth: even if an endpoint mistakenly returns data for the wrong tenant, this plug ensures the response won’t leak across tenant boundaries.
  • Flexible: the validation logic can be tailored to your response structure (for example, checking a tenant_id field in every object).
  • Lightweight: the plug only runs for JSON responses and doesn’t interfere with other content types.

This pattern is useful for tenant isolation, auditing, or any other scenario where you need to enforce response-level guarantees before data leaves your system.