Ash Authentication - Customize Magic Link (Part 2)
In Part 1, we set up a custom Magic Link workflow that enforces profile creation (First Name/Last Name) upon registration.
In Part 2, we tackle a critical requirement for modern applications: Compliance. Whether it is GDPR, Terms of Service, or a Privacy Policy, you often need to prove exactly when a user agreed to a specific version of a document.
We will expand our application to:
- Manage versioned legal documents.
- Capture request metadata (IP Address and User Agent) for audit trails.
- Intercept the Magic Link registration to enforce document acknowledgment.
You can find the code here Part 2 or, check out the main branch here: Ash Authentication - Customize Magic Link
Designing the Legal Domain
We need a system that tracks different types of documents (e.g., Privacy Policy, Terms) and versions them. We also need to track who agreed to which version.

The Document Version Resource
First, we create a resource to hold the text content of our legal documents. We use an effective_from date to handle versioning, allowing us to draft new policies before they go live.
Run the generator:
mix ash.gen.resource CustomizeAshAuthentication.Legal.DocumentVersion \
--uuid-primary-key id \
--attribute content:string:required:public \
--attribute effective_from:utc_datetime \
--attribute type:atom \
--timestamps \
--extend postgresNow, let's refine the resource. We need specific read actions to find the currently active version of a specific document type (e.g., the latest Privacy Policy).
We restrict types to ensure consistency.
# lib/customize_ash_authentication/legal/document_version.ex
attributes do
# ... generated attributes
attribute :type, :atom do
constraints one_of: [:privacy_policy, :terms_of_service]
allow_nil? false
end
end
actions do
defaults [:read]
create :create do
primary? true
accept [:content, :type, :effective_from]
end
read :get_latest_by_type do
get? true
argument :type, :atom do
allow_nil? false
end
filter expr(type == ^arg(:type) and effective_from < now())
prepare build(sort: [effective_from: :desc], limit: 1)
end
read :list_latest do
filter expr(effective_from < now())
prepare build(distinct: [:type], distinct_sort: [effective_from: :desc])
end
endGenerate and run the migration:
mix ash.codegen add_document_version
mix ash.migrateThe User Acknowledgement Resource
This resource acts as the link between a User and a DocumentVersion. It serves as the immutable audit log.
Run the generator:
mix ash.gen.resource CustomizeAshAuthentication.Legal.UserAcknowledgement \
--uuid-primary-key id \
--attribute ip:string:required \
--attribute user_agent:string:required \
--relationship belongs_to:user:CustomizeAshAuthentication.Accounts.User:required \
--relationship belongs_to:document_version:CustomizeAshAuthentication.Legal.DocumentVersion:required \
--extend postgresWe need to make a few adjustments to this resource. We will add a create_timestamp to record when they agreed, and add a unique identity so a user doesn't sign the exact same version twice. If a user is deleted, their acknowledgements go with them. We do not delete document versions, therefore we do not cascade deletions on acknowledgements.
# lib/customize_ash_authentication/legal/user_acknowledgement.ex
postgres do
table "user_acknowledgements"
repo CustomizeAshAuthentication.Repo
references do
# If a user is deleted, their acknowledgements go with them.
# We DO NOT cascade delete on document_version; legal history must be preserved.
reference :user, on_delete: :delete, index?: true
reference :document_version, index?: true
end
end
attributes do
# ... existing attributes
create_timestamp :inserted_at
end
identities do
identity :unique_user_document_version, [:user_id, :document_version_id]
endGenerate and run the migration:
mix ash.codegen add_user_acknowledgement
mix ash.migrateConnecting the User
Now, update the User resource to make these relationships accessible. We also add an aggregate. This is a performance optimization that allows us to get a list of IDs of documents the user has signed without loading all the associated data rows.
# lib/customize_ash_authentication/accounts/user.ex
relationships do
# ... existing code
has_many :acknowledgements, CustomizeAshAuthentication.Legal.UserAcknowledgement
many_to_many :document_version_acknowledgements,
CustomizeAshAuthentication.Legal.DocumentVersion do
join_relationship :acknowledgements
end
end
aggregates do
list :acknowledged_document_version_ids, :document_version_acknowledgements, :id
endCapturing Metadata (IP & User Agent)
For a legal acknowledgement to hold weight, we should capture the context of the request. In Phoenix/Ash, we can capture the IP address and User Agent in a Plug and pass it to the Ash Context. To achieve that we use the :shared key which will be propagated through managed relationships as documented here.
We will also use AshAuthentication.AddOn.AuditLog.IpPrivacy to anonymize the IP address (truncating it), ensuring we don't violate privacy laws while trying to comply with others.
Note: Ensure you remove the original auth_routes line from your main scope. We are moving it into this new scope to apply the metadata plug specifically to authentication actions.
# lib/customize_ash_authentication_web/router.ex
defp put_ash_request_context_metadata(conn, _opts) do
ip =
conn.remote_ip
|> :inet.ntoa()
|> to_string()
|> AshAuthentication.AddOn.AuditLog.IpPrivacy.apply_privacy(:truncate, %{})
user_agent =
conn
|> Plug.Conn.get_req_header("user-agent")
|> List.first()
Ash.PlugHelpers.set_context(conn, %{shared: %{client_ip: ip, user_agent: user_agent}})
end
scope "/", CustomizeAshAuthenticationWeb do
pipe_through [:browser, :put_ash_request_context_metadata]
auth_routes AuthController, CustomizeAshAuthentication.Accounts.User, path: "/auth"
endNote: This customization requires Ash Authentication < 4.12 or ≥ 5.0.0-rc.0 to work correctly due to a bug in earlier versions affecting the
:sharedcontext. If necessary, update yourmix.exs:{:ash_authentication, "~> 5.0.0-rc.0", override: true}.
The Acknowledgement Logic
Now we configure the UserAcknowledgement resource to pull that metadata out of the context and save it automatically when a record is created.
# lib/customize_ash_authentication/legal/user_acknowledgement.ex
actions do
defaults [:read]
create :create do
primary? true
accept [:document_version_id]
argument :accepted, :boolean do
allow_nil? false
end
change before_action(fn %{context: context} = changeset, _context ->
changeset
|> Ash.Changeset.force_change_attribute(:ip, context[:client_ip])
|> Ash.Changeset.force_change_attribute(:user_agent, context[:user_agent])
end)
validate argument_equals(:accepted, true),
message: "An acknowledgment is required"
end
endExposing the Code Interface
To make our LiveView easier to write, we expose specific actions via the Code Interface:
# lib/customize_ash_authentication/legal.ex
resource CustomizeAshAuthentication.Legal.DocumentVersion do
define :list_latest_document_versions,
action: :list_latest
define :create_document_version,
action: :create,
args: [:content, :type, {:optional, :effective_from}]
define :get_latest_document_version_by_type,
action: :get_latest_by_type,
args: [:type]
endUpdating the User Sign-In Action
Finally, update the User resource to accept these acknowledgements during the Magic Link sign-in process.
# lib/customize_ash_authentication/accounts/user.ex
create :sign_in_with_magic_link do
# ... existing profile arguments
argument :acknowledgements, {:array, :map}
# ... existing profile changes
change manage_relationship(:acknowledgements, type: :create)
endThe Magic Link LiveView
This is where everything comes together. When the user clicks the Magic Link, we check which active documents they haven't signed yet and present them in the form.

Loading User Data
We need to load the acknowledged_document_version_ids aggregate when we fetch the user.
# lib/customize_ash_authentication_web/live/magic_sign_in.ex
defp assign_user_assigns(%{assigns: %{email: email}} = socket) do
case Accounts.get_user_by_email!(email,
load: [
:acknowledged_document_version_ids,
profile: [:first_name, :last_name, :full_name]
],
not_found_error?: false,
authorize?: false
) do
nil ->
socket
|> assign(:profile, %{})
|> assign(:acknowledged_document_version_ids, []) # New user has signed nothing
|> assign(:action_label, label(true))
|> assign(:name, nil)
user ->
socket
|> assign(:profile, profile_to_map(user.profile))
|> assign(:acknowledged_document_version_ids, user.acknowledged_document_version_ids)
|> assign(:action_label, label(false))
|> assign(:name, if(user.profile, do: user.profile.full_name, else: nil))
end
endPreparing the Form
We calculate which documents are missing and pre-fill the form params. AshPhoenix will see these params and generate the necessary nested form structures.
# lib/customize_ash_authentication_web/live/magic_sign_in.ex
defp assign_form(
%{
assigns: %{
token: token,
acknowledged_document_version_ids: acknowledged_document_version_ids,
profile: profile
}
} = socket
) do
# 1. Fetch all currently active document versions
# 2. Filter out the ones the user ID list already contains
document_versions =
CustomizeAshAuthentication.Legal.list_latest_document_versions!()
|> Enum.filter(fn document_version ->
document_version.id not in acknowledged_document_version_ids
end)
# Prepare the params for the form.
# We set 'accepted' to false initially so the user must click it.
acknowledgements =
document_versions
|> Enum.map(fn document_version ->
%{
accepted: false,
document_version_id: document_version.id
}
end)
form =
AshPhoenix.Form.for_create(
User,
:sign_in_with_magic_link,
params: %{
profile: profile,
acknowledgements: acknowledgements,
token: token
},
as: "user",
load: [:profile, :acknowledgements],
forms: [
profile: [
type: :single,
resource: Profile,
create_action: :create_on_registration
],
acknowledgements: [
type: :list,
resource: CustomizeAshAuthentication.Legal.UserAcknowledgement,
create_action: :create
]
]
)
socket
|> assign(:form, to_form(form))
|> assign(:document_versions, document_versions)
endRendering the Form
We use inputs_for to loop over the required acknowledgements. We also use a helper acknowledgement_label to generate a link to the document. For simplicity we'll just transform the type atom to the title. But normally you should use something like Gettext to handle the labels properly.
Note: Generating HTML in a helper and using raw() carries security risks (XSS). Ensure that the data passed into the string (like @type) is controlled by you, or sanitize it properly.
# lib/customize_ash_authentication_web/live/magic_sign_in.ex
def render(assigns) do
~H"""
<!-- ... form with other inputs -->
<.inputs_for :let={acknowledgement} field={@form[:acknowledgements]}>
<input
type="hidden"
name={acknowledgement[:document_version_id].name}
value={acknowledgement[:document_version_id].value}
/>
<.input
field={acknowledgement[:accepted]}
type="checkbox"
label={
@document_versions |> Enum.at(acknowledgement.index) |> acknowledgement_label()
}
/>
</.inputs_for>
<!-- ... submit button -->
"""
end
defp acknowledgement_label(%{effective_from: effective_from, type: type}),
do: """
I accept the
<a href=#{~p"/documents/#{type}"} class="underline" target="_blank">
#{type |> Atom.to_string() |> CustomizeAshAuthenticationWeb.DocumentController.type_to_title()}.
</a>
Effective since: #{effective_from |> DateTime.to_date() |> Date.to_string()}
"""Note: You may need to update your core_components.ex input function to support Phoenix.HTML.raw(@label) inside the checkbox label span, or the HTML tags will be escaped and visible to the user.
See the following snippet.
# lib/customize_ash_authentication_web/components/core_components.ex
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>
{Phoenix.HTML.raw(@label)}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
endDisplaying the Documents
We need a controller to actually render the document content when the user clicks the link in the checkbox label.
# lib/customize_ash_authentication_web/controllers/document_controller.ex
defmodule CustomizeAshAuthenticationWeb.DocumentController do
use CustomizeAshAuthenticationWeb, :controller
alias CustomizeAshAuthentication.Legal
plug :put_layout, false
def show(conn, %{"type" => type}) do
case Legal.get_latest_document_version_by_type(type) do
{:ok, document} ->
render(conn, :show, content: document.content, title: type_to_title(type))
_ ->
conn
|> put_flash(:error, "Document not available.")
|> redirect(to: ~p"/")
end
end
# A simple helper to pretty-print the original atom (e.g. :privacy_policy -> "Privacy Policy")
def type_to_title(type) when is_binary(type) do
type
|> String.split("_")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
end# lib/customize_ash_authentication_web/controllers/document_html.ex
defmodule CustomizeAshAuthenticationWeb.DocumentHTML do
use CustomizeAshAuthenticationWeb, :html
def show(assigns) do
~H"""
<div class="grid min-h-screen place-items-center bg-base-100 py-12 px-4">
<div class="mx-auto w-full max-w-2xl">
<h1 class="text-2xl sm:text-3xl lg:text-4xl py-6 flex justify-center">{@title}</h1>
<div class="p-6">
{raw(@content)}
</div>
</div>
</div>
"""
end
endAdd the route to router.ex:
# lib/customize_ash_authentication_web/router.ex
scope "/", CustomizeAshAuthenticationWeb do
pipe_through :browser
# ... existing routes
get "/documents/:type", DocumentController, :show
endTesting
Let's verify the workflow. Start your server with iex -S mix phx.server and create a dummy document version:
CustomizeAshAuthentication.Legal.create_document_version!(
"<p>Your deepest secrets will be revealed to the universe.</p>",
:privacy_policy,
DateTime.utc_now()
)And another one:
CustomizeAshAuthentication.Legal.create_document_version!(
"<p>After death, ownership of your soul will be claimed by the Devil.</p>",
:terms_of_service,
DateTime.utc_now()
)- Navigate to
http://localhost:4000/sign-in. - Enter your email.
- Navigate to the development mailbox
http://localhost:4000/dev/mailboxfor the link. - Click the link. You should see the First Name and Last Name fields (from Part 1) AND a checkbox for the Privacy Policy and the Terms of Service.
- If you try to submit without checking the box, validation will fail.
- Once you submit, check the database (or Ash Resource). You will see a
UserAcknowledgementrecord containing your (anonymized) IP and User Agent.

Conclusion
You now have a fully compliant "Just-In-Time" registration flow. The user is only persisted when they have provided all necessary profile data and legally agreed to your terms.
It also forces existing users to re-accept new versions of terms when they next log in. But if you have a long living token, they might use your service without re-accepting the new terms for a while.