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:
- SP-initiated: the user hits the app, the app redirects to Entra ID with a
<samlp:AuthnRequest>, Entra authenticates, then POSTs a signed<samlp:Response>(containing the<saml:Assertion>) back to the SP’s Assertion Consumer Service (ACS) URL. This is the secure default — prefer it. - IdP-initiated: the user starts at the Entra My Apps portal or a deep link, and Entra POSTs an unsolicited assertion to the ACS. There is no
AuthnRequestto correlate, so it is more replay-prone and many SPs reject it unless explicitly enabled.
The assertion lifecycle, abbreviated:
- SP redirects the browser to Entra’s SAML SSO endpoint (HTTP-Redirect binding).
- Entra authenticates the user and applies Conditional Access.
- Entra builds an assertion: a
Subject(the NameID),Conditions(NotBefore/NotOnOrAfterand anAudienceRestriction), anAuthnStatement, and anAttributeStatement(your claims). - Entra signs it with the token signing certificate and POSTs it to the ACS (HTTP-POST binding).
- 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:
- Attribute — map straight from a directory field such as
user.userprincipalname,user.mail,user.department,user.jobtitle,user.objectid, oruser.onpremisessamaccountname. - Transformation — derive the value (next section).
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
groupsoverage 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.
- Entra extension attributes (formerly “schema/directory extensions”) are created against an application. Once provisioned with values, they appear in the claim source picker as
user.extension_<appId-without-dashes>_<attributeName>. - On-premises extension attributes (
extensionAttribute1–15) sync via Entra Connect and appear asuser.extensionattribute1…user.extensionattribute15.
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:
ExtractMailPrefix()— turnjane.doe@acme.comintojane.doe(handy when the SP keys on the local part).Join()— concatenate two attributes with a separator, e.g.<department>-<jobTitle>.ToLowercase()/ToUppercase()— normalize case, which some case-sensitive SPs require.Regex Replace— match a source with a .NET regex and emit a value built from named capture groups.
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:
- Signing Option: Sign SAML assertion vs Sign SAML response vs Sign both. Match what the SP validates — getting this wrong yields valid-looking assertions the SP rejects.
- Signing Algorithm: prefer SHA-256. Only fall back to SHA-1 for legacy SPs that genuinely cannot do better.
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.
- Create a new certificate in the SAML Signing Certificate blade. It is created inactive; the current one keeps signing.
- 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.
- Activate the new certificate (Make certificate active). Entra immediately signs with it; SPs that picked up both keys keep validating without interruption.
- Confirm real sign-ins succeed against the new key (sign-in logs, SAML trace).
- 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:
- Format
emailAddresswith sourceuser.mailoruser.userprincipalname— common, human-readable, but breaks if the user’s email ever changes. - Format
persistentwith a stable source likeuser.objectid— the durable choice when the SP supports it, because it survives email and name changes. - Format
unspecified— only when the SP explicitly asks for it.
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.
- 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.
- 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 theNotOnOrAfterwindow. - 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>'
- 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
- Wrong signing option. “Sign assertion” vs “sign response” mismatches produce assertions that look fine in a trace but the SP silently rejects. Confirm against SP docs first.
- Unstable NameID. Using
user.mailas NameID for an SP that keys accounts on it means a single mailbox rename creates a duplicate account. Usepersistent+user.objectidwherever the SP allows. - Groups overage. Don’t ship a tenant-wide groups claim to a SAML app; scope to app-assigned groups so you never hit the overage cutoff that drops the claim entirely.
- Conditional claim ordering. Last match wins — verify the actual emitted value for edge users (guests who are also admins) in a live trace, not on paper.
- Silent certificate expiry. No notification list means the first signal is a tenant-wide outage. Automate expiry alerts off
keyCredentials.endDateTime.
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.