Identity Azure

Configuring SAML 2.0 SSO for a Custom Enterprise App in Entra ID with Advanced Claims Mapping

Most “SSO is broken” tickets for SAML apps trace back to three things: a NameID the service provider did not expect, a claim the app silently requires but nobody mapped, or a signing certificate that rolled at 2 a.m. without the SP picking up the new key. This walkthrough federates a non-gallery enterprise application with Entra ID end to end, then spends most of its time on the parts that bite: advanced claims, transformations, and certificate rollover.

I assume you hold Cloud Application Administrator or Application Administrator, and that the target app speaks SAML 2.0 as a service provider (SP).

1. SAML 2.0 fundamentals you need before clicking anything

Entra ID is the identity provider (IdP). Your app is the service provider (SP). Two flows matter:

The assertion lifecycle, abbreviated:

  1. SP redirects the browser to Entra’s SAML SSO endpoint (HTTP-Redirect binding).
  2. Entra authenticates the user and applies Conditional Access.
  3. Entra builds an assertion: a Subject (the NameID), Conditions (NotBefore / NotOnOrAfter and an AudienceRestriction), an AuthnStatement, and an AttributeStatement (your claims).
  4. Entra signs it with the token signing certificate and POSTs it to the ACS (HTTP-POST binding).
  5. SP validates the signature against the IdP’s published certificate, checks audience and time window, then establishes a session.

The audience in the assertion must equal the SP’s expected Entity ID, and the assertion’s clock window is tight (Entra issues short-lived assertions). Clock skew on the SP and a wrong Entity ID are the two most common validation failures.

2. Register a non-gallery enterprise application and exchange metadata

In the portal: Entra ID > Enterprise applications > New application > Create your own application, choose Integrate any other application you don’t find in the gallery (Non-gallery), name it, and create. Then open Single sign-on > SAML.

You can also create it via Graph. A non-gallery SSO app is instantiated from the generic SAML template 8adf8e6e-67b2-4cf2-a259-e3dc5476c621:

# Create the app + service principal from the generic non-gallery SAML template
az rest --method POST \
  --url "https://graph.microsoft.com/v1.0/applicationTemplates/8adf8e6e-67b2-4cf2-a259-e3dc5476c621/instantiate" \
  --headers "Content-Type=application/json" \
  --body '{"displayName": "acme-billing-saml"}'

That returns an application and a servicePrincipal object ID — the service principal is the enterprise app you configure for SSO.

Metadata is a two-way exchange. Get the SP’s metadata XML (Entity ID, ACS URL, NameID format, sometimes a request-signing certificate) from your app vendor and use it to populate the Basic SAML Configuration — the portal’s Upload metadata file button reads it directly. Going the other way, give the SP Entra’s IdP metadata so it can trust your signature:

https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml?appid=<application-id>

The appid query parameter is what makes this app-specific metadata — it pins the document to the certificate configured for this enterprise app, which matters once you start rolling certificates per app.

3. Identifier (Entity ID), Reply URL, sign-on URL, and relay state

These four fields are the contract with the SP. Get them exactly right.

Field SAML term What it is Notes
Identifier Audience / Entity ID Uniquely identifies the SP to Entra Becomes <AudienceRestriction>. Often a URN or URL; must match byte-for-byte
Reply URL Assertion Consumer Service (ACS) Where Entra POSTs the assertion Must be HTTPS. You can list several and mark one default
Sign on URL SP login endpoint Where SP-initiated login starts Required for IdP-initiated tiles in My Apps; leave blank to disable IdP-initiated
Relay State RelayState Opaque SP deep-link target Optional; typically a path the SP interprets post-login

Same thing via Graph, patching the service principal’s samlSingleSignOnSettings plus the application’s web.redirectUris and identifierUris:

# Identifier(s) / Audience live on the application object
az rest --method PATCH \
  --url "https://graph.microsoft.com/v1.0/applications/<app-object-id>" \
  --headers "Content-Type=application/json" \
  --body '{
    "identifierUris": ["https://billing.acme.example/saml/metadata"],
    "web": { "redirectUris": ["https://billing.acme.example/saml/acs"] }
  }'

Reply URLs must be HTTPS and are matched exactly, including trailing slashes. A mismatch surfaces as sign-in error AADSTS50011 (reply URL does not match). If the SP accepts the assertion at a different path than its metadata advertised, add that exact path here.

4. Map standard and custom claims, including directory extension attributes

Open Single sign-on > Attributes & Claims. By default Entra emits the NameID plus the four classic claims (name, givenname, surname, emailaddress) under the legacy http://schemas.xmlsoap.org/ws/... namespaces. Most non-gallery SPs need more, and they are picky about the exact claim name (the SAML attribute Name) — confirm it from the SP’s documentation.

Add a claim with Add new claim: set the Name (and optional Namespace), then a Source:

For a clean groups claim, use the dedicated Add a group claim rather than hand-rolling it: choose Security groups / Groups assigned to the application, and set the value to Group ID for stability, or sAMAccountName / Cloud-only group display names where the SP expects names.

Past a threshold (200 for SAML), Entra drops the groups claim and emits a groups overage indicator pointing at a Graph query the SP usually cannot follow. Scope the claim to Groups assigned to the application to keep the set small, or filter groups by attribute.

Directory extension attributes

Custom attributes synced from on-prem or created in the cloud show up as sources once they exist.

Create a directory extension via Graph by adding an extensionProperty to an app registration:

az rest --method POST \
  --url "https://graph.microsoft.com/v1.0/applications/<app-object-id>/extensionProperties" \
  --headers "Content-Type=application/json" \
  --body '{
    "name": "costCenter",
    "dataType": "String",
    "targetObjects": ["User"]
  }'

The resulting attribute is referenceable as extension_<appIdNoDashes>_costCenter. Populate it on users with PATCH on the user object using that same property name, then it becomes selectable as a claim source.

5. Claim transformations and conditional claims

Two features cover the messy real-world cases.

Transformations chain string functions over one or two inputs. Pick Transformation as the claim source, then build a pipeline. Common, genuinely useful ones:

The regex option is the one worth practicing. Say the SP wants only the numeric site code from an attribute like SITE-0427-EU. Choose Regex Replace, set the source attribute, a regex such as SITE-(?<code>\d{4})-\w{2}, and an output pattern of {code}. Entra emits 0427. The editor evaluates a sample value inline, so test before saving rather than debugging in production.

Conditional claims emit different values depending on user type or group membership. In the claim’s Claim conditions, add rows ordered top to bottom; the last matching row wins, so order matters. A typical pattern for an app whose role claim must differ for partners:

User type Scoped groups Source / value
Any Billing-Admins Value: admin
Members (none) Attribute: user.jobtitle
External guests (none) Value: partner-readonly

Because evaluation is last-match-wins, put the broad fallback first and the most specific override last. Here, a guest who is also in Billing-Admins ends up partner-readonly unless you reorder — exactly the kind of subtlety to verify against the live trace.

6. Token signing certificate management and zero-downtime rollover

Entra signs assertions with a token signing certificate unique to the enterprise app. By default it is valid for three years. Rolling it without an outage is a coordination dance because the SP caches the public key from your metadata and validates every signature against it.

Find the certificate config under Single sign-on > SAML Signing Certificate. Decide on the signing options here too:

Zero-downtime rollover procedure

The trick: Entra lets you stage a new, inactive certificate alongside the active one, and the app-specific federation metadata can advertise both keys so an SP that re-reads metadata trusts the new key before you activate it.

  1. Create a new certificate in the SAML Signing Certificate blade. It is created inactive; the current one keeps signing.
  2. Have the SP ingest the updated metadata so it trusts both public keys. If the SP cannot consume multiple keys, you have a hard cutover and must schedule a window.
  3. Activate the new certificate (Make certificate active). Entra immediately signs with it; SPs that picked up both keys keep validating without interruption.
  4. Confirm real sign-ins succeed against the new key (sign-in logs, SAML trace).
  5. Remove the old certificate once confident, shrinking the metadata back to a single key.

You can wire step 2 into automation by notifying ops before expiry. Set the notification address and pull current certificate state via Graph:

# Inspect the signing keys/credentials and their validity windows on the SP
az rest --method GET \
  --url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>?\$select=keyCredentials,preferredTokenSigningKeyThumbprint"

The preferredTokenSigningKeyThumbprint tells you which staged key is currently active; keyCredentials lists every key with startDateTime/endDateTime so you can alert on impending expiry.

Set Notification Email Addresses on the certificate to a monitored distribution list, not an individual. Entra emails before expiry, and a silent expiry is a tenant-wide outage for that app with no warning to end users — just sudden signature-validation failures.

7. Assign users and groups, NameID format, and just-in-time

Entra enterprise apps default to assignment required: only assigned users and groups get a token. Keep it that way for SAML apps unless the SP genuinely should be open to the whole tenant.

# Require assignment, then assign a group (appRoleId all-zeros = default access)
az rest --method PATCH \
  --url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>" \
  --headers "Content-Type=application/json" \
  --body '{"appRoleAssignmentRequired": true}'

az rest --method POST \
  --url "https://graph.microsoft.com/v1.0/groups/<group-object-id>/appRoleAssignments" \
  --headers "Content-Type=application/json" \
  --body '{
    "principalId": "<group-object-id>",
    "resourceId": "<sp-object-id>",
    "appRoleId": "00000000-0000-0000-0000-000000000000"
  }'

NameID is the SP’s primary key for the user, so choosing the wrong format orphans accounts. In Attributes & Claims > Unique User Identifier (Name ID) pick a format and a source:

Pick the format the SP documents, and prefer a stable source. If the SP provisions accounts just-in-time on first SAML login (no SCIM), the assertion is the only data it gets — every attribute needed at account creation (email, display name, role) must be a claim from day one, or you get a malformed or under-privileged JIT account. SAML does not deprovision; pair JIT with SCIM provisioning or an offboarding runbook so leavers are actually removed on the SP side.

Enterprise scenario

A logistics company federated a legacy WMS vendor app over SAML so warehouse staff could SSO. SP-initiated login worked for the pilot group, then broke for everyone after a tenant-wide Conditional Access change forced MFA. The WMS embedded its SAML login inside a kiosk WebView with no cookie persistence and no support for the interactive MFA redirect, so every assertion request died on AADSTS50076 (MFA required, interactive challenge needed). The vendor could not patch the WebView, and dropping MFA tenant-wide was off the table for the security team.

The fix had two parts. First, scope a Conditional Access policy to the WMS service principal that requires a compliant device (the kiosks were Intune-enrolled) instead of interactive MFA, satisfying the strong-auth bar without a browser challenge. Second, the WMS keyed accounts on sAMAccountName, but these were cloud-synced identities whose UPN had been changed during a domain migration — so the NameID had to come from the on-prem attribute, not the UPN.

Unique User Identifier (Name ID)
  Format: persistent
  Source attribute: user.onpremisessamaccountname

Pinning NameID to user.onpremisessamaccountname with the persistent format kept the SP’s existing account keys stable through the UPN churn, while the device-compliance policy let MFA-equivalent assurance pass silently inside the kiosk. The lesson the team took away: a CA policy that is correct tenant-wide can still be wrong for one SAML app whose client cannot do an interactive redirect — always scope auth requirements per enterprise app and validate against the real client, not a desktop browser.

Verify

Work from cheapest signal to most detailed.

  1. Test SSO from the portal: SAML SSO blade > Test runs an SP- or IdP-initiated flow as yourself or another user and decodes the resulting assertion, flagging missing claims.
  2. Capture a real assertion: install SAML-tracer (browser extension), reproduce the login, and read the POST to the ACS. Confirm the Issuer, the <Audience> matches your Identifier, the NameID format and value, every expected attribute, and the NotOnOrAfter window.
  3. Decode it offline if you only have the raw POST body:
# The SAMLResponse form field is URL-encoded base64 (NOT deflated on the POST binding)
python3 -c "import sys,urllib.parse,base64; print(base64.b64decode(urllib.parse.unquote(sys.argv[1])).decode())" '<SAMLResponse-value>'
  1. Read the sign-in logs: Entra ID > Sign-in logs, filter to the app. Failures carry an AADSTS code that names the cause directly.
Code Meaning Usual fix
AADSTS50011 Reply URL mismatch Add the SP’s exact ACS URL (scheme, host, path) to Reply URLs
AADSTS50105 User not assigned to the app Assign the user/group, or relax assignment requirement
AADSTS650056 Misconfigured app / missing permission Re-check identifier, claims config, and consent
AADSTS75011 Authentication method mismatch Reconcile Conditional Access / requested auth context with the SP

Checklist

Pitfalls and next steps

Next, lift this into infrastructure-as-code (the Terraform azuread provider models the application, service principal, claims mapping policy, and certificate), add a Conditional Access policy scoped to the app, and stand up SCIM provisioning so JIT-created accounts are deprovisioned when users leave.

Entra IDSAMLSSOEnterprise AppClaims

Comments

Keep Reading