This guide walks you through adding a SCIM 2.0 server to an existing Ash
application — wiring up AshScim on your user resource, exposing a
/scim/v2 endpoint, and authenticating identity providers (Okta, Azure AD,
etc.) with bearer tokens.
If you don't yet have an Ash application, start with the Ash Getting Started guide. If you also want logged-in users (separate concern from SCIM), follow the AshAuthentication Getting Started guide first — AshScim integrates cleanly with it.
What you'll build
By the end of this guide:
- Your
Userresource is exposed as a SCIMUserresource at/scim/v2/Users. - Optionally, a
Groupresource is exposed at/scim/v2/Groups, with group memberships managed via SCIM PATCH ops. - IdPs authenticate using bearer JWTs minted by AshAuthentication, stored in your existing token resource so they can be revoked at any time.
Install with Igniter (recommended)
mix igniter.install ash_scim
This:
- adds
:ash_scimto your deps and.formatter.exs, - adds the
AshScim.Userextension to your user resource (defaultMyApp.Accounts.User— pass--userto override), - adds the
:scim_external_idand:activeattributes if they're not already present, - adds a default
scim doblock with sensible mappings, - adds the
AshScim.Checks.AshScimInteractionpolicy bypass, - generates a
MyAppWeb.ScimRoutermodule wired to your accounts domain.
When AshAuthentication is also a dep of the project, the router defaults
to {AshScim.Auth.AshAuthenticationToken, otp_app: :my_app}. Otherwise
it defaults to {AshScim.Auth.StaticBearer, tokens: {:env, "SCIM_BEARER_TOKEN"}}.
The installer is idempotent — re-running it on a partially-installed app picks up where it left off. The printed notices cover the two manual steps that remain: mounting the router in your endpoint, and minting a token.
If you'd rather wire things by hand, the rest of this guide covers the equivalent manual setup.
Add the dependency (manual)
# mix.exs
defp deps do
[
# ...
{:ash_scim, "~> 0.1"}
]
endAdd :ash_scim to your .formatter.exs:
# .formatter.exs
[
import_deps: [..., :ash_scim]
]Add the extension to your User resource
Whatever your existing User resource looks like, add AshScim.User to its
extensions: list and declare the SCIM mapping in a scim do ... end
block:
defmodule MyApp.Accounts.User do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
# Add AshScim.User to your existing extensions
extensions: [AshAuthentication, AshScim.User]
scim do
map :userName, attribute: :email
map :active, attribute: :active
map :externalId, attribute: :scim_external_id
complex :name do
map :givenName, attribute: :first_name
map :familyName, attribute: :last_name
end
multivalued :emails do
map :value, attribute: :email
map :primary, value: true
map :type, value: "work"
end
end
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false, public?: true
attribute :first_name, :string, public?: true
attribute :last_name, :string, public?: true
attribute :active, :boolean, default: true, public?: true
attribute :scim_external_id, :string, public?: true
end
identities do
identity :unique_email, [:email]
end
# ...your authentication block, actions, etc...
endThe scim do block describes how SCIM JSON projects to and from Ash
attributes. See Multi-valued
attributes for the full breakdown of
map/complex/multivalued.
Add the policy bypass
If your resource uses Ash.Policy.Authorizer, add a bypass for AshScim's
internal interactions so the router's reads, creates, updates, and
destroys aren't blocked by your user-facing policies:
policies do
bypass AshScim.Checks.AshScimInteraction do
authorize_if always()
end
# ... your existing policies ...
endSee Policies & the bypass check for the rationale.
Optional: add Group + Membership for SCIM groups
Most IdPs sync groups in addition to users. The simplest model is a
Group resource plus a Membership join resource:
defmodule MyApp.Accounts.Membership do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
attribute :user_id, :string, allow_nil?: false, public?: true
attribute :group_id, :uuid, allow_nil?: false, public?: true
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
identities do
identity :user_in_group, [:user_id, :group_id]
end
enddefmodule MyApp.Accounts.Group do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshScim.Group]
scim do
map :displayName, attribute: :name
map :externalId, attribute: :scim_external_id
multivalued :members do
relationship :memberships
map :value, attribute: :user_id
end
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :scim_external_id, :string, public?: true
end
actions do
defaults [:read, :destroy, create: :*]
update :update do
primary? true
accept :*
require_atomic? false
end
end
policies do
bypass AshScim.Checks.AshScimInteraction do
authorize_if always()
end
end
relationships do
has_many :memberships, MyApp.Accounts.Membership do
destination_attribute :group_id
public? true
end
end
endAdd both resources to your domain:
defmodule MyApp.Accounts do
use Ash.Domain
resources do
resource MyApp.Accounts.User
resource MyApp.Accounts.Group
resource MyApp.Accounts.Membership
# ... existing resources ...
end
endRun a codegen + migration so the new tables exist:
mix ash.codegen add_scim_groups
mix ecto.migrate
Define a SCIM router module
defmodule MyAppWeb.ScimRouter do
use AshScim.Router,
domains: [MyApp.Accounts],
auth: {AshScim.Auth.AshAuthenticationToken, otp_app: :my_app},
base_url: "https://my-app.example.com/scim/v2"
endThis module is a Plug. The auth: option chooses how IdP requests are
authenticated — see Authentication for the
built-in options.
Mount the router in your Phoenix endpoint
SCIM URLs include : characters in schema IDs (e.g.
/Schemas/urn:ietf:params:scim:schemas:core:2.0:User), which Phoenix's
default Plug.Static middleware rejects. The cleanest fix is to dispatch
SCIM requests at the endpoint level, before Plug.Static:
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
socket "/live", Phoenix.LiveView.Socket, # ...
# Dispatch SCIM requests before Plug.Static.
plug :scim_dispatch
defp scim_dispatch(%Plug.Conn{path_info: ["scim", "v2" | rest]} = conn, _opts) do
conn = %{conn | path_info: rest, script_name: conn.script_name ++ ["scim", "v2"]}
conn
|> MyAppWeb.ScimRouter.call(MyAppWeb.ScimRouter.init([]))
|> Plug.Conn.halt()
end
defp scim_dispatch(conn, _opts), do: conn
plug Plug.Static,
at: "/",
from: :my_app,
only: MyAppWeb.static_paths()
# ... rest of the standard endpoint plugs ...
endMint a SCIM bearer token
SCIM requests authenticate as a service-account user with a JWT whose
purpose claim is "scim". You create one by inserting into your
existing token resource via a normal Ash action — typically from an admin
UI or an iex console attached to your production node.
The simplest approach for development:
# A one-off script — priv/repo/scim_token.exs
require Ash.Query
email = "scim-service@my-app.local"
user =
case MyApp.Accounts.User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false) do
{:ok, %MyApp.Accounts.User{} = u} -> u
{:ok, nil} ->
MyApp.Accounts.User
|> Ash.Changeset.for_create(:create, %{email: email, active: true})
|> Ash.create!(authorize?: false)
end
{:ok, jwt, _claims} = AshAuthentication.Jwt.token_for_user(user, %{"purpose" => "scim"})
IO.puts("SCIM bearer token:\n#{jwt}")Run with mix run priv/repo/scim_token.exs. Paste the JWT into your IdP's
SCIM provisioning configuration (or use it for curl testing).
For production, expose this as a "Generate SCIM token" action on your
existing token resource and call it from your admin UI. Tokens stored
this way can be revoked at any time using
AshAuthentication.TokenResource.revoke/2.
Verify the setup
Smoke-test from the command line:
TOKEN=... # paste the JWT printed above
curl -sS https://my-app.example.com/scim/v2/ServiceProviderConfig \
-H "Authorization: Bearer $TOKEN"
curl -sS https://my-app.example.com/scim/v2/Users \
-H "Authorization: Bearer $TOKEN"
You should get a 200 with a SCIM ServiceProviderConfig document, and
a 200 with a ListResponse (initially containing just your service
account user).
Run a real compliance suite
For a thorough end-to-end check, run the
scim2-tester compliance
suite against your endpoint:
pipx install scim2-cli
scim -u https://my-app.example.com/scim/v2 \
-h "Authorization:Bearer $TOKEN" \
test
This exercises every endpoint AshScim emits, validates the responses against RFC 7643/7644, and reports any deviations.
What next?
- Multi-valued attributes — single-attribute vs relationship-backed.
- Filter syntax — what your IdP can filter on.
- PATCH operations — what the router does
with each kind of
PatchOp. - Authentication —
StaticBearervs AshAuthentication-backed JWTs. - Policies & the bypass check.
- Limitations — known gaps.