This guide is the operator-facing reference for the moment after SAML validation has already succeeded. Use it to decide which verified identity field becomes your local account anchor, whether login-time JIT is appropriate, and where Relyra's responsibility ends.
Overview
Relyra verifies the SAML response, normalizes the successful login into a verified payload, and then stops at a host-owned seam. It does not decide whether a local user should be looked up, linked, created, updated, suspended, or authorized for application-specific roles.
In the Phoenix ACS path, that seam is Relyra.UserMapper.map_attributes/3.
The controller passes the verified login result plus the resolved connection
into the mapper, and the host application returns the user-shaped map that its
session layer needs next.
Treat this guide as a local identity policy document, not as a SAML theory overview. The question is not "what can the IdP emit?" The question is "which verified value should our app trust as the durable local anchor?"
UserMapper behaviour
Relyra.UserMapper.map_attributes/3 is the host-owned seam between verified
SAML identity and your application's account policy.
On the Phoenix ACS success path, Relyra calls the mapper like this:
{:ok, login_result} = Relyra.consume_response(response_xml, consume_opts)
{:ok, mapped_user} = Relyra.UserMapper.map_attributes(login_result, login_result.connection, opts)That means the adapter receives the verified %Relyra.LoginResult{} plus the
resolved connection. In the ACS path, read identity data from
login_result.principal, not from invented top-level fields:
%Relyra.LoginResult{
principal: %Relyra.Principal{
name_id: name_id,
name_id_format: name_id_format,
attributes: attributes
},
connection: connection
}Keep one contract explicit in host code and operator docs:
- Relyra verifies the response, the signature, the replay constraints, and the normalized identity payload.
- Your mapper decides how to interpret those verified facts for local account lookup, linking, creation, update, and authorization policy.
- Relyra does not ship a user database, background provisioning engine, or SCIM lifecycle controller.
The examples below are intentionally host-owned. They show real adapter modules
that read from %Relyra.LoginResult{principal: %Relyra.Principal{...}} and
then call application code to enforce local identity rules.
Relyra owns / Host owns
Relyra owns
- Response validation, signature verification, replay checks, and the verified login payload.
- The normalized identity facts exposed through
Relyra.LoginResultandRelyra.Principal, such asname_id,name_id_format, and released attributes. - The mapper and session seams where the host application takes over.
Host owns
- Choosing the local account anchor.
- Looking up an existing account, deciding whether a new account may be created, and deciding which fields are safe to update on login.
- Authorization, tenant membership, offboarding, manual account linking, and every lifecycle action outside the successful login event.
- Any SCIM workflow or adjacent lifecycle sync system.
The practical boundary is simple: Relyra proves "this IdP asserted these facts and the trust path verified." Your application decides what those facts mean for an account in your domain.
Choose your identity anchor first
Choose the anchor before you write mapping code or enable JIT. This is the decision that determines whether future IdP cleanup is harmless or becomes an account migration.
Anchor-quality rules:
- Best: a stable opaque identifier that the IdP treats as durable for the life of the user.
- Acceptable with care: email or another human-readable attribute when your app already treats that field as the canonical identity and your org can tolerate renames, aliases, and reuse risk.
- Poor choice:
transientidentifiers or any field the IdP explicitly treats as session-scoped or presentation-only.
Anchor-stability warning:
- If the IdP changes the NameID source or NameID format after users already exist, your app may see a different local identifier for the same human.
- If the anchor is email-based, mailbox rename, domain migration, and recycled addresses can split or relink accounts unexpectedly.
- If you anchor on a convenient attribute now and later move to a different source, plan that as an account-migration project, not as a docs cleanup.
Before enabling any automatic create-or-update flow, capture the chosen anchor in operator docs the same way you would capture an ACS URL or signing certificate. Anchor drift is a trust-boundary change, not a cosmetic IdP edit.
Keep this aligned with the generic SAML runbook, which already treats NameID choice as a trust-boundary decision rather than an admin-console default.
Pattern 1: NameID as local identifier
Use this pattern when the IdP can emit a NameID that is already the durable identifier your application wants to anchor on.
This is usually the safest pattern when:
- The IdP can emit a stable
persistentNameID. - Your host app does not need a separate internal identity key for the same user.
- You want the smallest gap between the validated SAML identity and the local lookup key.
What the host app should do:
- Read the verified NameID from the login payload.
- Look up the local account by that anchor.
- Fail closed if the account must already exist and no match is found.
What breaks this pattern:
- The IdP emits
transientNameID. - The IdP emits
unspecifiedNameID but the actual source changes between environments or later admin edits. - The org treats email-style NameID as durable even though addresses can change.
If you pick NameID, make the exact source and format part of the deployment contract. "Whatever the IdP currently sends" is not a stable policy.
Example: NameID as local identifier
Use this pattern when principal.name_id is already the durable account anchor
your host app wants to trust.
defmodule MyApp.Relyra.NameIdUserMapper do
@behaviour Relyra.UserMapper
alias MyApp.Accounts
alias Relyra.Error
alias Relyra.LoginResult
alias Relyra.Principal
@transient_name_id "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
@impl true
def map_attributes(
%LoginResult{
principal: %Principal{
name_id: name_id,
name_id_format: name_id_format,
attributes: attributes
}
},
connection,
_opts
) do
cond do
is_nil(name_id) or name_id == "" ->
{:error, Error.new(:invalid_identity_anchor, "NameID is required for this connection")}
name_id_format == @transient_name_id ->
{:error, Error.new(:invalid_identity_anchor, "Transient NameID cannot anchor local users")}
user = Accounts.get_user_by_saml_subject(connection.connection_id, name_id) ->
{:ok,
%{
user_id: user.id,
identity_anchor: %{type: :name_id, value: name_id, format: name_id_format},
email: first_attribute(attributes, ["email", "mail", "EmailAddress"]),
roles: normalize_list(attributes["groups"] || attributes[:groups])
}}
true ->
{:error, Error.new(:user_not_found, "No local account is linked to this NameID")}
end
end
defp first_attribute(attributes, keys) do
Enum.find_value(keys, fn key -> attributes[key] || attributes[String.to_atom(key)] end)
end
defp normalize_list(nil), do: []
defp normalize_list(values) when is_list(values), do: values
defp normalize_list(value), do: [value]
endThis stays inside the real seam: Relyra already proved the SAML identity, and the host app decides whether that verified NameID is allowed to resolve a local account.
Pattern 2: Attribute as local identifier
Use this pattern when NameID is not the right durable anchor for your app, but another verified attribute is.
Common examples:
- The app anchors on employee number, HR identifier, or another stable directory key.
- The IdP uses NameID for presentation or federation convenience, but the app already has a different canonical local identifier.
- The app intentionally uses email as the lookup key and accepts the rename policy and migration burden that comes with it.
This pattern needs more discipline than Pattern 1 because you are choosing a field that may look stable in one directory but be mutable in another. Ask:
- Who owns this attribute?
- Can it change on rename, domain move, or tenant merge?
- Can the value be recycled for a different person later?
- Does every environment release it consistently?
If the answer is "sometimes," document the migration and relinking plan now. Attribute anchors are viable, but they are only safe when the host application owns the consequences of churn.
Example: Attribute as local identifier
Use this pattern when your host app anchors on a verified attribute such as an employee number or another durable directory key instead of NameID.
defmodule MyApp.Relyra.EmployeeNumberUserMapper do
@behaviour Relyra.UserMapper
alias MyApp.Accounts
alias Relyra.Error
alias Relyra.LoginResult
alias Relyra.Principal
@employee_number_keys ["employeeNumber", "employee_id", "EmployeeNumber"]
@impl true
def map_attributes(
%LoginResult{
principal: %Principal{
name_id: name_id,
name_id_format: name_id_format,
attributes: attributes
}
},
connection,
_opts
) do
with {:ok, employee_number} <- required_attribute(attributes, @employee_number_keys),
%{} = user <- Accounts.get_user_by_employee_number(connection.connection_id, employee_number) do
{:ok,
%{
user_id: user.id,
identity_anchor: %{type: :employee_number, value: employee_number},
saml_subject: %{name_id: name_id, name_id_format: name_id_format},
email: first_attribute(attributes, ["email", "mail", "EmailAddress"]),
display_name: first_attribute(attributes, ["display_name", "DisplayName", "cn"])
}}
else
{:error, :missing_attribute} ->
{:error, Error.new(:invalid_identity_anchor, "Required employee number attribute is missing")}
nil ->
{:error, Error.new(:user_not_found, "No local account is linked to this employee number")}
end
end
defp required_attribute(attributes, keys) do
case first_attribute(attributes, keys) do
nil -> {:error, :missing_attribute}
value -> {:ok, value}
end
end
defp first_attribute(attributes, keys) do
Enum.find_value(keys, fn key -> attributes[key] || attributes[String.to_atom(key)] end)
end
endThis is still host-owned policy. Relyra does not decide that employee number is the right local anchor. Your app makes that decision and owns the migration plan if the IdP ever changes the released field.
Pattern 3: JIT create or update
JIT means your host application decides, during a successful login, whether to create a new local account or update a subset of fields on an existing one. Relyra does not provision the user for you. It gives you verified identity input and a mapper seam.
In the Phoenix path, that input arrives as the verified login result passed into
Relyra.UserMapper.map_attributes/3. The host app can read stable identity data
from login_result.principal and then decide whether local account creation or
update is allowed.
Use JIT only after you are confident about the anchor decision. Otherwise you will automate duplicate-account creation at login speed.
JIT is usually reasonable when:
- The app allows first-login account creation for the target population.
- The anchor is stable enough that repeated logins resolve to the same account.
- The fields you plan to update on login are low-risk profile projections, not broader authorization or lifecycle controls.
JIT is risky when:
- The app has approval gates or entitlement rules that should not be bypassed by successful authentication alone.
- Several directories or tenants can release overlapping identifiers.
- Another lifecycle system already creates and links accounts independently.
Keep the output of Relyra.UserMapper.map_attributes/3 narrow and
host-shaped. The mapper should return the identity data your app needs for local
lookup or create-or-update decisions, not pretend to be a full provisioning
engine.
Example: JIT create or update
Use this pattern when the host application allows login-time account creation or limited profile projection after a successful lookup decision.
defmodule MyApp.Relyra.JitUserMapper do
@behaviour Relyra.UserMapper
alias MyApp.Accounts
alias Relyra.Error
alias Relyra.LoginResult
alias Relyra.Principal
@impl true
def map_attributes(
%LoginResult{
principal: %Principal{
name_id: name_id,
name_id_format: name_id_format,
attributes: attributes
}
},
connection,
_opts
) do
with {:ok, email} <- required_attribute(attributes, ["email", "mail", "EmailAddress"]),
{:ok, anchor} <- stable_anchor(name_id, name_id_format, email) do
projected_user = %{
saml_subject: %{name_id: name_id, name_id_format: name_id_format},
email: email,
first_name: first_attribute(attributes, ["given_name", "givenname", "FirstName"]),
last_name: first_attribute(attributes, ["family_name", "sn", "LastName"]),
roles: normalize_list(attributes["groups"] || attributes[:groups])
}
case Accounts.find_user_for_saml_login(connection.connection_id, anchor) do
nil ->
create_user_if_allowed(connection, anchor, projected_user)
user ->
update_allowed_fields(user, projected_user)
end
end
end
defp create_user_if_allowed(connection, anchor, projected_user) do
if Accounts.jit_enabled_for_connection?(connection.connection_id) do
Accounts.create_saml_user(connection.connection_id, anchor, projected_user)
else
{:error, Error.new(:user_not_found, "JIT is disabled for this connection")}
end
end
defp update_allowed_fields(user, projected_user) do
Accounts.update_user_from_saml(user, Map.take(projected_user, [:email, :first_name, :last_name]))
end
defp stable_anchor(name_id, _name_id_format, _email) when is_binary(name_id) and name_id != "" do
{:ok, {:name_id, name_id}}
end
defp stable_anchor(_name_id, _name_id_format, email) when is_binary(email) and email != "" do
{:ok, {:email, email}}
end
defp stable_anchor(_name_id, _name_id_format, _email) do
{:error, Error.new(:invalid_identity_anchor, "JIT requires a documented stable anchor")}
end
defp required_attribute(attributes, keys) do
case first_attribute(attributes, keys) do
nil -> {:error, Error.new(:invalid_identity_anchor, "Required email attribute is missing")}
value -> {:ok, value}
end
end
defp first_attribute(attributes, keys) do
Enum.find_value(keys, fn key -> attributes[key] || attributes[String.to_atom(key)] end)
end
defp normalize_list(nil), do: []
defp normalize_list(values) when is_list(values), do: values
defp normalize_list(value), do: [value]
endThis is still not Relyra-owned provisioning. The host app chooses whether JIT is enabled, which fields may change on login, and whether some other lifecycle system is the real source of truth.
JIT decision tree
Use this decision tree before enabling login-time create or update:
- Is the local identity anchor stable across rename, tenant, and format changes? If no, do not enable JIT yet.
- Is the anchor released consistently in every environment this connection will serve? If no, fix the IdP release contract first.
- Does your app allow successful login to create a local account without a separate approval step? If no, use lookup-only mapping and reject unknown users.
- If the account already exists, which fields are safe to update on login? Limit this to profile projection, not lifecycle authority.
- Is another system already creating, linking, or deprovisioning these accounts? If yes, choose one source of truth before enabling JIT.
Recommended outcomes:
- Stable anchor + no other lifecycle source: JIT create or update can be reasonable.
- Stable anchor + external lifecycle owner: prefer lookup and limited projection.
- Unstable anchor: fix the anchor first, then reconsider JIT.
SCIM is a non-goal
SCIM lifecycle ownership is outside Relyra's scope. Relyra covers login-time assertion validation and the host-owned mapping seam that follows. It does not ship a user directory, background lifecycle sync, or deprovisioning authority.
If your organization uses SCIM, keep the responsibility split explicit:
- SCIM or an adjacent lifecycle system owns long-lived account creation, disablement, and reconciliation.
- Relyra owns the verified login event.
- Your host app decides how those two systems meet.
Safety warning:
- Running JIT create-or-update and SCIM at the same time without one clear source of truth can create duplicate accounts, broken links, or account drift.
- The risk is highest when JIT uses one anchor and SCIM uses another, or when one system updates fields the other treats as authoritative.
- A safe mixed model needs one written owner for account existence, one written anchor for linking, and one explicit rule for which fields may change at login.
If you need both, define the authoritative anchor and lifecycle owner first. Without that decision, simultaneous JIT and SCIM is not additive resilience. It is two competing account writers.