diff --git a/lib/mv_web/helpers/join_description_renderer.ex b/lib/mv_web/helpers/join_description_renderer.ex
new file mode 100644
index 0000000..121b02b
--- /dev/null
+++ b/lib/mv_web/helpers/join_description_renderer.ex
@@ -0,0 +1,70 @@
+defmodule MvWeb.Helpers.JoinDescriptionRenderer do
+ @moduledoc """
+ Renders a custom field's `join_description` into Phoenix-safe HTML for the
+ public join form.
+
+ The renderer auto-links two patterns into `` tags:
+
+ - Markdown links of the form `[text](url)` (processed first)
+ - bare `http(s)://` URLs in the remaining text
+
+ All other content is HTML-escaped: only `` tags are ever
+ emitted, so arbitrary HTML in the input is rendered as inert text. This is a
+ defense-in-depth measure — `join_description` is admin-set content, never
+ end-user input — but the renderer must not become a vector for injecting
+ arbitrary markup.
+
+ Markdown links are matched before bare URLs and their matched region is
+ consumed, so a Markdown link whose URL also looks like a bare URL is linked
+ exactly once (no nested anchors).
+ """
+
+ @markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
+ @bare_url ~r/(https?:\/\/[^\s<]+)/
+ @bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
+
+ @doc """
+ Converts `value` to a Phoenix-safe HTML iolist.
+
+ Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
+ links rendered and all other text HTML-escaped.
+ """
+ @spec render(String.t() | nil) :: Phoenix.HTML.safe()
+ def render(nil), do: {:safe, ""}
+
+ def render(value) when is_binary(value) do
+ {:safe, render_segments(value)}
+ end
+
+ # Split on Markdown links first; for each non-Markdown segment, link bare URLs;
+ # everything that is not a link is HTML-escaped.
+ defp render_segments(text) do
+ Regex.split(@markdown_link, text, include_captures: true)
+ |> Enum.map(&render_markdown_or_plain/1)
+ end
+
+ defp render_markdown_or_plain(segment) do
+ case Regex.run(@markdown_link, segment) do
+ [^segment, label, url] -> anchor(url, label)
+ _ -> render_plain(segment)
+ end
+ end
+
+ # Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
+ defp render_plain(segment) do
+ Regex.split(@bare_url, segment, include_captures: true)
+ |> Enum.map(fn part ->
+ if Regex.match?(@bare_url_anchored, part) do
+ anchor(part, part)
+ else
+ escape(part)
+ end
+ end)
+ end
+
+ defp anchor(url, label) do
+ ["", escape(label), ""]
+ end
+
+ defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
+end
diff --git a/test/mv_web/helpers/join_description_renderer_test.exs b/test/mv_web/helpers/join_description_renderer_test.exs
new file mode 100644
index 0000000..62eb91d
--- /dev/null
+++ b/test/mv_web/helpers/join_description_renderer_test.exs
@@ -0,0 +1,85 @@
+defmodule MvWeb.Helpers.JoinDescriptionRendererTest do
+ @moduledoc """
+ Tests for the join-description renderer that auto-links raw URLs and Markdown
+ links while escaping all other content.
+ """
+ use ExUnit.Case, async: true
+ use ExUnitProperties
+
+ alias MvWeb.Helpers.JoinDescriptionRenderer
+
+ defp html(value) do
+ value
+ |> JoinDescriptionRenderer.render()
+ |> Phoenix.HTML.safe_to_string()
+ end
+
+ describe "render/1" do
+ test "converts a raw URL to an anchor tag" do
+ result = html("Akzeptiere https://example.com/dsgvo")
+
+ assert result =~ ~s("
+ assert result =~ "Akzeptiere "
+ end
+
+ test "converts Markdown [text](url) to an anchor tag with the link text" do
+ result = html("[Datenschutzerklärung](https://example.com/dsgvo)")
+
+ assert result =~ ~s(Datenschutzerklärung"
+ end
+
+ test "returns an empty safe string for nil input" do
+ assert JoinDescriptionRenderer.render(nil) == {:safe, ""}
+ end
+
+ test "escapes arbitrary HTML in non-link text" do
+ result = html("")
+
+ refute result =~ "