Phoenix HTML Escaping in Dynamic Content

TL;DR

When building HTML strings manually in Phoenix, use Phoenix.HTML.html_escape/1 then Phoenix.HTML.safe_to_string/1. Prevents XSS from user content.

The Problem

You’re generating HTML dynamically - maybe parsing markdown-like syntax or building custom tags. User content goes into your HTML string.

# DANGEROUS - XSS vulnerability
defp format_heading(text) do
~s(<h2>#{text}</h2>)
end

If text is <script>alert('xss')</script>, you’ve got a problem.

The Solution

defp format_heading(text) do
escaped = text |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
~s(<h2>#{escaped}</h2>)
end

The two-step process:

  1. html_escape/1 returns a safe tuple {:safe, "escaped content"}
  2. safe_to_string/1 extracts the escaped string

Real Example

Parsing simple markdown-like content:

defp format_block("## " <> heading) do
escaped = heading |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
~s(<h3 class="detail__h3">#{escaped}</h3>)
end
defp format_block("- " <> item) do
escaped = item |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
~s(<li>#{escaped}</li>)
end
defp format_block(paragraph) do
escaped = paragraph |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
~s(<p class="detail__p">#{escaped}</p>)
end

When Phoenix Does It Automatically

In HEEx templates, interpolation is auto-escaped:

<h2>{@user_content}</h2> <%# Automatically escaped %>

Manual escaping is only needed when building HTML strings outside templates.

References