Most “SSO is broken” tickets for SAML apps trace back to three things, and none of them is a code bug. It is a NameID the service provider did not expect, so the app either can’t find the user or silently provisions a duplicate. It is a claim the app requires but nobody mapped, so login “works” and then the user lands on a permission-denied page. Or it is a token signing certificate that rolled at 2 a.m. and the SP never picked up the new public key, so every assertion since is a valid signature the SP now rejects. SAML 2.0 is a mature, boring, extremely well-specified protocol — which is exactly why the failures are always in the integration seams, not the protocol. This article federates a non-gallery enterprise application with Entra ID end to end, then spends the bulk of its length on the seams that bite: the assertion contract, advanced and conditional claims, group claims and the overage cutoff, certificate rollover without an outage, and reading a real assertion when it all goes wrong.
Entra ID is the identity provider (IdP); your application is the service provider (SP). The whole job is to make Entra emit an assertion the SP will accept — the right identity data, signed with a key the SP trusts, delivered to the URL the SP published — and to keep that true through certificate rotations, UPN changes, group growth, and Conditional Access tightening. Get the four contract fields right (Identifier/Entity ID, Reply URL/ACS, NameID, and the signing option) and most tickets never happen. This guide assumes you hold Cloud Application Administrator or Application Administrator, that the target app speaks SAML 2.0 as an SP, and that you are comfortable with az/Graph, the portal, and XML. Every step carries a portal path and a Graph/CLI command, and — because this is a mid-incident reference — the assertion structure, claim sources, AADSTS codes, and rollover runbook are all scannable tables. By the end, when a federation breaks you will know whether the audience mismatched, the NameID drifted, the signature failed, a claim was never mapped, the group claim hit overage, the certificate expired, or Conditional Access blocked a client that cannot do an interactive redirect — and you will confirm which from the sign-in logs and a captured assertion in minutes, not hours.
What problem this solves
The gallery apps in Entra (Salesforce, ServiceNow, Workday, thousands more) ship with a pre-built SAML template: claim names, NameID format, and often the metadata exchange are pre-wired, so you fill in a subdomain and you’re done. A non-gallery app has none of that. It is a raw SAML 2.0 SP — a legacy warehouse-management system, a vendor’s on-prem app behind a reverse proxy, an internal tool your team wrote — and you are responsible for every field of the contract. Nothing tells you the SP keys accounts on sAMAccountName, requires a role attribute under a specific namespace, or validates the assertion signature but not the response signature; that knowledge lives in the vendor’s integration doc (often a PDF written by someone who left the company) and in a captured assertion. This article is the missing template plus the method to reverse-engineer the rest.
Without it you hit a repetitive set of failures: a trailing slash on the Reply URL → AADSTS50011 forever; an emailAddress NameID on user.mail that works until a mailbox rename creates a duplicate account; a tenant-wide groups claim that Entra silently drops past 150 groups so permissions vanish with no error; a signing certificate that expires with the notification going to a mailbox nobody reads. Every one is diagnosable and preventable — the point is that a senior engineer treats the assertion as a contract to validate, not a black box to trust.
Who hits this: every team federating a vendor or in-house SAML app that isn’t in the gallery. It bites hardest on legacy apps (picky about claim names and NameID, often SHA-1-only, sometimes IdP-init-only), apps inside kiosks or embedded WebViews (which choke on interactive MFA redirects), apps that provision just-in-time off the assertion (where a missing claim means a broken account), and tenants where group membership grows past the SAML overage threshold. The fix is almost never “turn SSO off and on again” — it’s “capture the assertion, compare it byte-for-byte to what the SP documents, and find the one field that’s wrong.”
Every failure class this article covers, the question it forces, and the first place to look:
| Failure class | What the SP is really saying | First question to ask | First place to look | Most common single cause |
|---|---|---|---|---|
| Sign-in error at Entra | “Entra refused before issuing” | Is there an AADSTS code? | Entra Sign-in logs (filter to app) | Reply URL / assignment / CA |
| SP rejects the assertion | “Signature/audience/NameID is wrong” | Does the trace show the assertion arriving? | SAML-tracer capture of the ACS POST | Wrong signing option or audience |
| Login works, app broken | “I got a user but not their attributes” | Are all required claims present? | Decode the assertion’s AttributeStatement | Missing/mis-named claim |
| Permissions suddenly gone | “Group claim vanished” | Is the user over the group cutoff? | Assertion for a groups overage link |
Group-claim overage (150/SAML) |
| All logins fail after a date | “Signature no longer validates” | Did a certificate roll or expire? | Signing-certificate blade + SP metadata | Cert rollover the SP didn’t ingest |
| One client fails, browsers pass | “This client can’t do the redirect” | Does it fail only in the kiosk/WebView? | Sign-in logs (AADSTS50076/50079) | CA/MFA vs a non-interactive client |
Learning objectives
By the end of this article you can:
- Trace a full SAML 2.0 exchange in both directions (SP-initiated and IdP-initiated) and name every element of the
AuthnRequest,Response, andAssertion, including which bindings and signatures apply where. - Register a non-gallery enterprise application for SAML SSO via portal and Graph, and complete the two-way metadata exchange.
- Configure the four contract fields exactly — Identifier, Reply URL (ACS), Sign-on URL, RelayState — with the SAML term, failure mode, and AADSTS code behind each.
- Map standard, custom, and directory-extension claims with exact names/namespaces, and build transformations and conditional claims with correct last-match-wins ordering.
- Emit a clean group claim, choose the right value (Group ID vs sAMAccountName vs display name), and handle the overage cutoff so a high-group-count user never silently loses permissions.
- Manage the token signing certificate and execute a zero-downtime rollover (stage inactive, publish both keys, activate, confirm, retire).
- Pair SAML with SCIM provisioning and Conditional Access scoped to the service principal, and understand why SAML’s lack of deprovisioning makes SCIM mandatory.
- Diagnose any failure from an AADSTS code and a SAML-tracer capture — reply-URL mismatch, unassigned user, unstable NameID, signing-option mismatch, overage, cert drift, MFA-in-a-WebView.
Prerequisites & where this fits
You should already understand the Entra application model: an app registration (the application object — the global definition of an app and its credentials) versus an enterprise application (the servicePrincipal object — the instance of that app in your tenant that you assign users to and configure SSO on). SAML SSO is configured on the service principal, while the Entity ID(s) live on the application object — a split that trips people up in Graph. If that distinction is fuzzy, read Entra ID App Registrations vs Enterprise Apps Explained first; this article assumes it. You should be able to run az rest/az ad in Cloud Shell, read and edit XML, and know your way around the Entra admin center’s Enterprise applications blade.
This sits in the Identity & Access / Application Integration track. Upstream is the protocol choice: modern apps should use OIDC where they can — see OIDC & OAuth 2.0 Flows in Entra ID: Authorization Code + PKCE and App Registration for OIDC: Confidential Client & Federated Credentials — reaching for SAML only when the SP speaks nothing else, which for legacy and vendor apps is often. A simpler sibling, Configuring SAML SSO for an Enterprise App: The Configuration Guide, walks the happy path; this is the advanced treatment of the parts that break. It pairs with Conditional Access at Scale: Personas & Authentication Context — a CA policy scoped to the app’s service principal is how you apply MFA/device/session controls — and, when the assertion needs on-prem directory data, with Entra Connect Sync Deep Dive: PHS, PTA & Seamless SSO, which populates onPremisesSamAccountName and the extensionAttribute1–15 you’ll map as claims.
Ownership splits predictably: the SP (vendor/app team) owns the ACS URL, Entity ID, NameID expectation, required claims, and signing-option requirement; you (Cloud App / App Admin) own the enterprise-app SSO config, Attributes & Claims, signing cert, Entity ID(s), and directory extensions; the identity team owns user.* values, group membership, and SCIM; the security team owns Conditional Access; and the app/network team owns ACS reachability, TLS, and any reverse proxy in front of the SP. When it breaks, that map tells you who confirms what.
Core concepts
Six mental models make every later section obvious.
SAML is signed XML about a browser user, delivered through the browser. Unlike OIDC (a token the client fetches and validates against a JWKS), SAML web SSO is the IdP POSTing signed XML through the browser — no back-channel token endpoint. Hence the design: the SP’s URL must be browser-reachable, the signature must validate against a certificate the SP holds in advance, and the assertion carries a tight time window and an audience so it can’t be replayed. Everything hard follows from trusting XML that arrived through an untrusted browser.
Entra is the IdP, your app is the SP, and there are exactly two flows. In SP-initiated SSO the app builds a <samlp:AuthnRequest>, redirects to Entra, and Entra POSTs a signed <samlp:Response> (wrapping the <saml:Assertion>) to the SP’s Assertion Consumer Service (ACS). In IdP-initiated SSO the user starts at My Apps and Entra POSTs an unsolicited assertion with no AuthnRequest to correlate. SP-initiated is the secure default — correlation closes a class of replay/injection issues, and many SPs reject unsolicited assertions. Prefer it; enable IdP-init only when the SP requires it.
The assertion is a contract with four load-bearing parts: a Subject with the NameID (the SP’s primary key), Conditions (a NotBefore/NotOnOrAfter window and an AudienceRestriction = the SP’s Entity ID), an AuthnStatement, and an AttributeStatement (your claims). The SP validates all four — signature, audience, time, NameID format — and rejects an otherwise-perfect assertion if any is off. The top two failures are a wrong Entity ID and clock skew.
The URL contract is unforgiving. The Entity ID / Identifier names the SP and becomes the AudienceRestriction (byte-for-byte). The Reply URL (ACS) is where Entra POSTs — HTTPS, matched exactly including trailing slash; a mismatch is AADSTS50011. The Sign-on URL starts SP-init and enables the IdP-init tile. RelayState is an opaque deep-link the IdP round-trips untouched. A wrong trailing slash or http vs https is the most common first-day failure.
Signing is a two-axis decision the SP dictates, not you. Entra can sign the assertion, the response, or both, with SHA-256 (prefer) or SHA-1 (legacy). The SP decides which it validates; sign at the wrong level and the assertion looks flawless in a trace but the SP silently rejects it — no AADSTS code, because Entra did nothing wrong. Read the SP doc, match the option, confirm in a trace. Optionally Entra encrypts the assertion to an SP-published certificate — rare, for sensitive attributes.
SAML authenticates; it does not provision or deprovision. An assertion is a login event, not a lifecycle event. If the SP creates accounts just-in-time (JIT), the assertion is the only data it gets, so every attribute needed at creation must be a claim from day one. And SAML has no concept of “this user left” — a leaver’s account, data, and licenses persist on the SP forever unless SCIM (System for Cross-domain Identity Management) or a manual runbook removes them. Federating without a deprovisioning story is how orphans and license waste accumulate.
The vocabulary in one table
The mental model side by side (the glossary repeats these for lookup):
| Concept | One-line definition | Where it lives | Why it matters |
|---|---|---|---|
| IdP | Identity provider — issues the assertion | Entra ID | The signer; the source of truth for identity |
| SP | Service provider — consumes the assertion | Your app | Validates and trusts the assertion |
| AuthnRequest | SP’s signed/unsigned request to authenticate | Sent SP→IdP (SP-init only) | Present only in SP-initiated flow |
| Response | The <samlp:Response> wrapping the assertion |
Posted IdP→SP (ACS) | May be signed; carries status + assertion |
| Assertion | The signed statement about the user | Inside the Response | The payload the SP acts on |
| NameID | The Subject identifier — SP’s primary key | <Subject> in the assertion |
Wrong/unstable → orphaned accounts |
| AudienceRestriction | Who the assertion is for (= Entity ID) | <Conditions> |
Must equal SP Entity ID or reject |
| ACS / Reply URL | Where the IdP POSTs the assertion | Enterprise app config + SP metadata | Mismatch → AADSTS50011 |
| Entity ID / Identifier | Unique name of the SP | Application object identifierUris |
Becomes the audience |
| Signing certificate | Key Entra signs assertions with | Enterprise app SAML blade | Rollover/expiry → SP rejects signature |
| Attributes & Claims | The mapped claim set | Enterprise app SSO blade | Missing/mis-named → app broken |
| Group claim | Group membership in the assertion | Attributes & Claims | Overage past the cutoff drops it |
| SCIM | Cross-domain provisioning protocol | Enterprise app Provisioning blade | Handles account create/update/delete |
| RelayState | Opaque SP deep-link round-tripped | Query param / config | Post-login landing page on the SP |
| AADSTS code | Entra’s sign-in error identifier | Sign-in logs | Names the Entra-side failure directly |
The SAML 2.0 exchange, element by element
Before you configure anything, you need the shape of the messages, because every portal field maps to an element in this XML and every failure is “field X didn’t match what the SP wanted.” We walk SP-initiated, then IdP-initiated, then dissect the assertion.
SP-initiated flow, step by step
The user is at the app and needs to log in. The full round trip, each step naming binding, direction, and what breaks:
| # | Step | Direction | Binding | What it carries | What breaks here |
|---|---|---|---|---|---|
| 1 | User requests a protected resource | Browser → SP | HTTP | — | — |
| 2 | SP builds <AuthnRequest>, redirects |
SP → Browser → IdP | HTTP-Redirect | Issuer, ACS URL, optional ForceAuthn/RequestedAuthnContext |
Malformed request → AADSTS750xx |
| 3 | Entra authenticates the user | Browser ↔ IdP | HTTP | Credentials, MFA | CA blocks (50076/50079/53000) |
| 4 | Entra applies Conditional Access | (internal) | — | Policy evaluation | Grant not satisfiable |
| 5 | Entra builds + signs the assertion | (internal) | — | Subject, Conditions, Attributes | Missing claim / unmapped attribute |
| 6 | Entra auto-submits <Response> to ACS |
IdP → Browser → SP | HTTP-POST | Signed Response + Assertion | AADSTS50011 (reply URL) |
| 7 | SP validates signature, audience, time | (internal to SP) | — | — | Wrong signing option / audience / skew |
| 8 | SP creates a session, honours RelayState | SP → Browser | HTTP | Session cookie, redirect to deep link | JIT gaps if account created now |
The critical detail in step 2: the AuthnRequest (HTTP-Redirect binding) is DEFLATE-compressed, then base64, then URL-encoded into a SAMLRequest parameter — you must inflate it, not just base64-decode. The response in step 6 (HTTP-POST binding) is base64 but NOT deflated. That asymmetry is why a decode one-liner that works on the response fails on the request.
IdP-initiated flow and when to allow it
IdP-initiated SSO skips steps 1–2: the user clicks a tile in My Apps, Entra authenticates and POSTs an unsolicited response to the ACS. With no AuthnRequest, the SP must accept a response that correlates to nothing. The trade-off:
| Aspect | SP-initiated | IdP-initiated |
|---|---|---|
| Starting point | The app | My Apps / deep link |
AuthnRequest present |
Yes | No |
InResponseTo correlation |
Yes (ties response to request) | No (unsolicited) |
| Replay/injection surface | Lower | Higher — a captured response could be replayed |
| RelayState origin | SP sets it, IdP round-trips | IdP config default, or fixed |
| SP support | Universal | Not all SPs accept it |
| Entra requirement | Nothing special | Sign-on URL populated (enables the tile) |
| Recommendation | Default | Only if the SP needs it |
Leave the Sign-on URL blank to disable the IdP-init tile when the SP is SP-init-only; populate it only when the SP expects unsolicited assertions, and ensure it enforces the short lifetime and a replay cache since it can’t lean on InResponseTo.
Anatomy of the assertion
What Entra emits (abbreviated, namespaces trimmed) — each block maps to a portal setting:
<samlp:Response ID="_..." IssueInstant="2026-05-14T09:12:03Z" Destination="https://billing.acme.example/saml/acs">
<saml:Issuer>https://sts.windows.net/<tenant-id>/</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="_..." IssueInstant="2026-05-14T09:12:03Z">
<saml:Issuer>https://sts.windows.net/<tenant-id>/</saml:Issuer>
<ds:Signature>...</ds:Signature> <!-- if "Sign assertion" -->
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">
3f2a...c9</saml:NameID> <!-- from your NameID source -->
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="2026-05-14T09:17:03Z"
Recipient="https://billing.acme.example/saml/acs"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2026-05-14T09:07:03Z" NotOnOrAfter="2026-05-14T09:17:03Z">
<saml:AudienceRestriction>
<saml:Audience>https://billing.acme.example/saml/metadata</saml:Audience> <!-- = Entity ID -->
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2026-05-14T09:12:00Z" SessionIndex="_...">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement> <!-- your Attributes & Claims -->
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress">
<saml:AttributeValue>jane.doe@acme.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="role">
<saml:AttributeValue>admin</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
Every element maps to a decision in the enterprise app — the Rosetta stone between the XML and the portal:
| Assertion element | Entra portal setting | Failure if wrong |
|---|---|---|
<Issuer> |
Fixed (https://sts.windows.net/<tenant>/) |
SP configured with wrong IdP Entity ID → reject |
<ds:Signature> placement |
SAML Signing Certificate → Signing Option | Signed at wrong level → SP silently rejects |
<NameID Format=...> |
Attributes & Claims → Unique User Identifier → format | Wrong format → SP can’t match |
<NameID> value |
Unique User Identifier → source attribute | Unstable source → orphaned/duplicate account |
<SubjectConfirmationData Recipient> |
= Reply URL (ACS) | Mismatch → AADSTS50011 |
<Conditions NotBefore/NotOnOrAfter> |
Platform (short window) | SP clock skew → “assertion expired” |
<Audience> |
Application identifierUris (Entity ID) |
Mismatch → SP “audience invalid” |
<AuthnContextClassRef> |
Driven by auth method / CA | SP requiring a specific context not met |
<Attribute Name=...> |
Attributes & Claims → each claim’s Name | Mis-named → app can’t read it |
<AttributeValue> |
Claim source (attribute/transform) | Empty/wrong → app malfunctions |
Keep this table open when you diff a captured assertion against the SP’s requirements — the three fields behind most “validates in the trace but the SP rejects it” confusion are signature placement, the NameID format URN, and the audience.
Register a non-gallery enterprise app and exchange metadata
Two paths: the portal (fast, discoverable) and Graph (repeatable, scriptable). Do it once by hand to learn the fields, then codify.
Portal path
In the Entra admin center: 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 (acme-billing-saml), create, then open Single sign-on and pick SAML. You now see the five-section SAML page — Basic SAML Configuration, Attributes & Claims, SAML Signing Certificate, Set up (URLs/metadata), and Test — each of which the rest of this article configures.
Graph / CLI path
Instantiate from the generic SAML template (fixed ID 8adf8e6e-67b2-4cf2-a259-e3dc5476c621), which creates both an application and a servicePrincipal:
# 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"}'
The response gives an application.id (app registration) and a servicePrincipal.id (enterprise app). You PATCH different objects for different settings:
| What you set | Which object | Graph resource | Key fields |
|---|---|---|---|
| Entity ID(s) / Audience | application |
/applications/{id} |
identifierUris |
| Reply URL(s) / ACS | application |
/applications/{id} |
web.redirectUris |
| SSO mode = SAML | servicePrincipal |
/servicePrincipals/{id} |
preferredSingleSignOnMode |
| NameID + claims policy | servicePrincipal |
claims-mapping policy | claimsMappingPolicies |
| Signing certificate | servicePrincipal |
/servicePrincipals/{id} |
keyCredentials, preferredTokenSigningKeyThumbprint |
| User/group assignment | servicePrincipal |
appRoleAssignedTo |
appRoleAssignmentRequired |
| SCIM provisioning | servicePrincipal |
synchronization jobs | synchronization |
Set the SSO mode to SAML on the service principal:
az rest --method PATCH \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>" \
--headers "Content-Type=application/json" \
--body '{"preferredSingleSignOnMode": "saml"}'
The two-way metadata exchange
Metadata is a two-way exchange; skipping either direction is a classic first-day failure. SP metadata → Entra tells Entra where to POST and what to name as audience; Entra metadata → SP lets the SP trust Entra’s signature and endpoints:
| Direction | Document | Provides | Consumed by | How to load |
|---|---|---|---|---|
| SP → Entra | SP SAML metadata XML | Entity ID, ACS URL(s), NameID format, optional SP signing/encryption certs | Entra (Basic SAML Config) | Portal Upload metadata file, or PATCH manually |
| Entra → SP | App-specific federation metadata | IdP Entity ID (Issuer), SSO URL, signing certificate(s) | The SP | Give the SP the metadata URL |
The SP’s metadata comes from your vendor (or you generate it in-house). The portal’s Upload metadata file button reads it and populates the Basic SAML Configuration — prefer this over hand-typing, since it also imports the NameID format and any SP request-signing certificate. Going the other way, hand the SP Entra’s app-specific federation metadata URL:
https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml?appid=<application-id>
The appid query parameter makes this app-specific — without it you get tenant-wide metadata pinned to the tenant default key; with it, the document is pinned to the certificate(s) configured for this enterprise app. That is what makes per-app certificate rollover possible: the SP re-reading this URL sees exactly the keys you’ve staged for this app. Always give the SP the ?appid= form.
In the portal you can copy the same ?appid= URL from SAML → “App Federation Metadata Url”, and for SPs that want only the certificate (not full metadata) use the “Certificate (Base64/Raw)” download on the same page.
The four contract fields: Identifier, Reply URL, Sign-on URL, RelayState
These four fields are the wire contract with the SP. Get any one wrong and login fails before the app ever runs. Here is each with its SAML term, its exact meaning, and its failure mode:
| Field (portal) | SAML term | What it is | Multiple allowed? | Failure if wrong |
|---|---|---|---|---|
| Identifier | Audience / Entity ID | Uniquely identifies the SP; becomes <AudienceRestriction> |
Yes (rare) | SP “audience invalid” — assertion rejected |
| Reply URL | Assertion Consumer Service (ACS) | Where Entra POSTs the assertion; HTTPS only | Yes (mark one default) | AADSTS50011 reply-URL mismatch |
| Sign on URL | SP login endpoint | Where SP-init starts; enables IdP-init tile | One | Blank → IdP-init disabled (sometimes intended) |
| Relay State | RelayState | Opaque SP deep-link, round-tripped | One default (SP can override per request) | Post-login lands on wrong page |
The rules that catch people: Identifier is matched byte-for-byte — a URN or URL exactly as the SP publishes it, so copy it from the metadata rather than retype it. Reply URL must be HTTPS and matched exactly including path and trailing slash (if the SP consumes at /saml/acs/ you must add that exact variant); you may list several and mark one default for IdP-initiated flows. Sign-on URL blank disables IdP-initiated — a feature when you want SP-init-only. RelayState is opaque — Entra round-trips whatever the SP sends, useful for “after login, land on /reports.” Set these via Graph on the correct objects:
# Identifier(s) / Audience and Reply URL(s) 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"] }
}'
# Terraform azuread provider — application + a SAML SP for the enterprise app
resource "azuread_application" "billing" {
display_name = "acme-billing-saml"
identifier_uris = ["https://billing.acme.example/saml/metadata"]
web {
redirect_uris = ["https://billing.acme.example/saml/acs"]
}
}
resource "azuread_service_principal" "billing" {
client_id = azuread_application.billing.client_id
preferred_single_sign_on_mode = "saml"
app_role_assignment_required = true
saml_single_sign_on {
relay_state = "/reports"
}
}
When the Reply URL is wrong the sign-in fails with AADSTS50011 and the error page names the URL that was sent by the SP versus what’s registered — read that page, it tells you exactly what to add. A subtle variant: if the SP is behind a reverse proxy that rewrites the host, the ACS the SP advertises in metadata may differ from where it actually consumes; add the real consumption URL.
Map standard, custom, and directory-extension claims
Open Single sign-on → Attributes & Claims. By default Entra emits the NameID plus four legacy claims under the http://schemas.xmlsoap.org/ws/2005/05/identity/claims/... namespace. Most non-gallery SPs need more and are picky about the exact claim name — the SAML attribute Name. Confirm every name from the SP’s integration doc; “email”, “emailaddress”, and the full legacy URI are three different claims to the SP.
The default claim set
What Entra emits out of the box:
| Default claim (short) | Full attribute Name | Default source | Typical SP use |
|---|---|---|---|
| Unique User Identifier | <Subject>/<NameID> |
user.userprincipalname |
The account key |
| name | .../identity/claims/name |
user.userprincipalname |
Display / login name |
| givenname | .../identity/claims/givenname |
user.givenname |
First name |
| surname | .../identity/claims/surname |
user.surname |
Last name |
| emailaddress | .../identity/claims/emailaddress |
user.mail |
Email / contact |
Claim sources — the full picture
When you Add a claim you set a Name (and optional Namespace), then a Source — where the value comes from. The complete menu:
| Source type | What it pulls from | Example reference | When to use |
|---|---|---|---|
| Attribute | A directory field on the user | user.department, user.jobtitle |
Straight pass-through of a directory value |
| Attribute (on-prem sync) | Synced AD attributes | user.onpremisessamaccountname |
SP keys on AD identity |
| Attribute (extensionAttribute) | AD extensionAttribute1–15 |
user.extensionattribute3 |
Repurposed AD custom fields |
| Directory extension | Entra schema extension | user.extension_<appId>_costCenter |
Cloud-created custom attribute |
| Transformation | Derived from 1–2 inputs | ExtractMailPrefix(user.mail) |
Reshape a value (next section) |
| Constant / Value | A literal string | Value: partner |
Fixed value, often in a condition |
| Claim conditions | Different value per user type/group | (see conditional claims) | Role differs by audience |
The most-used directory attributes, with the exact user.* reference:
| Directory attribute | user.* reference |
Notes |
|---|---|---|
| User principal name | user.userprincipalname |
Often the NameID / login |
user.mail |
May be null for unlicensed/synced users | |
| Object ID | user.objectid |
The stable, durable identifier |
| On-prem SAM account | user.onpremisessamaccountname |
Only for synced users |
| Employee ID | user.employeeid |
HR key, if populated |
| Department | user.department |
Common for coarse authz |
| Job title | user.jobtitle |
— |
| Company name | user.companyname |
Multi-tenant / multi-org SPs |
| Extension attrs | user.extensionattribute1..15 |
Synced from AD |
Directory extension attributes
When the SP needs a value the standard schema lacks (cost center, license tier, partner code), create a directory extension. Two kinds: Entra (cloud) directory extensions created against an app registration, which — once provisioned — appear as user.extension_<appId-without-dashes>_<attributeName>; and on-premises extension attributes (extensionAttribute1–15) that sync via Entra Connect and appear as user.extensionattribute1 … 15. Create a cloud extension via Graph:
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"]
}'
Referenceable as extension_<appIdNoDashes>_costCenter, populated on users with a PATCH using that same property name; only after it has values does it appear as a claim source:
az rest --method PATCH \
--url "https://graph.microsoft.com/v1.0/users/<user-object-id>" \
--headers "Content-Type=application/json" \
--body '{ "extension_<appIdNoDashes>_costCenter": "CC-4402" }'
The extension-attribute options compared, so you pick the right mechanism:
| Mechanism | Created where | Claim reference | Populated by | Best for |
|---|---|---|---|---|
On-prem extensionAttribute1–15 |
AD schema (existing) | user.extensionattributeN |
Entra Connect sync | Values that already live in AD |
| Entra directory extension | App registration | user.extension_<appId>_<name> |
Graph PATCH / provisioning | Cloud-native custom values |
| Custom security attributes | Tenant (governed) | Not directly claimable in SAML | Attribute admins | Governance/ABAC (not SAML claims) |
| SCIM-provisioned attribute | The SP itself | N/A (pushed, not claimed) | SCIM job | Attributes the SP stores, not reads from assertion |
A common trap: user.mail is null for synced users never assigned an Exchange/Microsoft 365 license, or cloud users where mail wasn’t set. If your NameID or a required claim sources from user.mail, those users get an empty value and the SP rejects or mis-provisions them. Source from user.userprincipalname or user.objectid when you need a guaranteed-present value.
Claim transformations and conditional claims
Two features cover the messy cases where a raw attribute isn’t quite what the SP wants.
Transformations
Pick Transformation as the claim source and build a pipeline of string functions over one or two inputs. The useful ones:
| Function | What it does | Input → output example | When you need it |
|---|---|---|---|
ExtractMailPrefix() |
Local part before @ |
jane.doe@acme.com → jane.doe |
SP keys on the username, not full email |
Join() |
Concatenate two values with a separator | Sales + Manager → Sales-Manager |
Composite role/group strings |
ToLowercase() |
Lower-case the value | Jane.Doe → jane.doe |
Case-sensitive SP matching |
ToUppercase() |
Upper-case the value | us → US |
SP expects upper-case codes |
Contains() / IfEmpty() / IfNotEmpty() |
Conditional value selection | pick fallback when source empty | Default a missing attribute |
Prefix() / Suffix() |
Add fixed leading/trailing text | 4402 → CC-4402 |
Namespacing a code |
RegexReplace() |
.NET regex with named groups → output pattern | SITE-0427-EU → 0427 |
Extract a substring by pattern |
The regex option is worth practicing — most powerful, easiest to get subtly wrong. To pull the numeric site code from SITE-0427-EU: Regex Replace, source attribute, regex SITE-(?<code>\d{4})-\w{2}, output pattern {code} → 0427. The editor evaluates a sample inline — always test there, because a regex that doesn’t match emits nothing (an empty claim), not an error, and an empty required claim breaks the SP silently.
Chains compose left to right: user.mail = Jane.Doe@ACME.com → ExtractMailPrefix() → Jane.Doe → ToLowercase() → jane.doe, emitted as preferred_username. Transformations also exist for the NameID (a dedicated option on the Unique User Identifier), useful when the SP’s account key is a reshaped attribute — a lower-cased UPN, or the mail prefix.
Conditional claims
Conditional claims emit different values by user type (member vs guest) or group membership. In a claim’s Claim conditions you add rows evaluated top to bottom, and — critically — the last matching row wins, so order is load-bearing. A typical pattern for an app whose role claim must differ for partners and admins:
| Row order | User type | Scoped groups | Source / value | Effect |
|---|---|---|---|---|
| 1 (fallback) | Any | (none) | Attribute: user.jobtitle |
Everyone defaults to their title |
| 2 | Members | (none) | Attribute: user.jobtitle |
Members keep title (explicit) |
| 3 | External guests | (none) | Value: partner-readonly |
Guests are read-only partners |
| 4 (override) | Any | Billing-Admins | Value: admin |
Anyone in Billing-Admins is admin |
Because evaluation is last-match-wins, put the broad fallback first and the most specific override last. Above, a guest who is also in Billing-Admins correctly ends up admin because row 4 is last; reorder so the guest rule comes last and that same user becomes partner-readonly despite being an admin — the edge case to verify against a live trace, not on paper. The User type dimension is Any / Members / All guests / AAD guests / External guests (“Members” = your org’s users); scoped groups require direct or transitive membership; and a row that matches nothing falls through to the claim’s base Source, so always set a sensible base.
Group claims and the overage problem
Groups are how most SPs do authorization, and the group claim is where SAML integrations quietly fail at scale. Use the dedicated Add a group claim, never a hand-rolled attribute claim, because it handles filtering and overage.
Choosing which groups and which value
Two independent choices — which groups, and what value per group:
| “Which groups” option | What it includes | When to use |
|---|---|---|
| Security groups | All security groups the user is in | Small tenants; risky at scale (overage) |
| Groups assigned to the application | Only groups assigned to this enterprise app | Recommended — bounds the set, avoids overage |
| Directory roles | Entra directory role memberships | SP authorizes on admin roles |
| All groups | Security + M365 + distribution | Almost never — huge, overage-prone |
| Group value emitted | What the SP receives | Stability | When to use |
|---|---|---|---|
| Group ID (objectId) | GUID | Stable — survives rename | Default; SP maps GUIDs to roles |
| sAMAccountName | On-prem group name | Stable if AD name stable | SP keys on AD group names (synced groups only) |
| Cloud-only group display names | Display name | Unstable — rename breaks it | SP insists on human-readable names |
| NetBIOSDomain\sAMAccountName | DOMAIN\Group |
As above | SP expects domain-qualified names |
Prefer Group ID for stability — a rename never breaks a GUID mapping — unless the SP can only match names.
The overage cutoff
The failure with no error message. Entra caps how many groups go in a token: for SAML the cutoff is 150, and past it Entra omits the groups claim and emits a groups overage indicator — a Graph URL the SP is expected to call for the full list. Almost no SAML SP follows that link, so the claim is gone and every group-derived permission vanishes silently, with a Success status and a valid assertion. It looks like “the app suddenly forgot who I am.”
The cutoff is 150 for SAML (and for implicit-flow JWTs; 200 for JWTs the app reads in code) — over it, the claim is dropped and the overage link emitted, same pattern for every token type. Avoidance strategies, best first:
| Strategy | How | Effect on overage |
|---|---|---|
| Scope to app-assigned groups | Group claim → “Groups assigned to the application” | Only the handful you assign count — usually far under 150 |
| Filter groups by attribute | Group claim → advanced → filter (e.g. name prefix) | Emit only groups matching a pattern |
| Emit app roles instead of groups | Define appRoles; assign users/groups to roles | Roles aren’t subject to the group cutoff |
| SP follows the overage link | SP calls the Graph URL with app permission | Rarely feasible for legacy SPs |
The strongest architectural answer for many apps is to stop shipping raw groups and ship app roles — define appRoles on the app registration, assign users and groups to them, and emit the role as a claim. Roles are curated, bounded, and meaningful to the SP, and sidestep the cutoff entirely. That pattern is covered in Entra ID Token Claims, App Roles & the On-Behalf-Of Flow; for SAML the mechanics are identical, just delivered as a SAML attribute rather than a JWT claim.
Token signing certificate: options, algorithm, and zero-downtime rollover
Entra signs assertions with a token signing certificate unique to the enterprise app, valid three years by default. 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.
Signing options and algorithm
Both live under Single sign-on → SAML Signing Certificate. Two decisions, both dictated by the SP:
| Signing Option | What gets signed | When the SP wants it |
|---|---|---|
| Sign SAML assertion | The <Assertion> element only |
Most common; SP validates the assertion signature |
| Sign SAML response | The <Response> envelope |
SP validates the outer response |
| Sign SAML response and assertion | Both | Strict SPs; belt-and-braces |
| Signing Algorithm | Use | Note |
|---|---|---|
| SHA-256 | Prefer always | Modern default; secure |
| SHA-1 | Legacy fallback only | Deprecated; only for SPs that genuinely can’t do SHA-256 |
The signing option is the number-one cause of “the assertion looks perfect in the trace but the SP rejects it with no AADSTS code.” If the SP validates the response signature and you only signed the assertion, the SP sees an unsigned response and rejects it while Entra logs a clean success. Read the SP doc, match the option, confirm with a trace.
The blade also shows the thumbprint (matching preferredTokenSigningKeyThumbprint for the active key), Status (new certs start Inactive; only the active key signs), Expiration (default +3 years), and Notification Email (defaults to the creator’s address — change it to a monitored DL).
The zero-downtime rollover procedure
The trick: Entra stages a new, inactive certificate alongside the active one, and the app-specific federation metadata advertises both public keys — so an SP that re-reads it trusts the new key before you activate it, and activation is seamless for any metadata-consuming SP. The full runbook:
| Step | Action | Portal / command | State after | Risk if skipped |
|---|---|---|---|---|
| 1 | Create a new certificate | SAML Signing Certificate → “New Certificate” | New key exists, inactive; old still signs | — |
| 2 | Have the SP ingest updated metadata | Re-point SP at ?appid= metadata URL |
SP trusts both public keys | Hard cutover — the crux |
| 3 | Verify the SP shows both keys | SP admin console / support | Confirmed dual-trust | Activation would break the SP |
| 4 | Activate the new certificate | “Make certificate active” | Entra signs with the new key | — |
| 5 | Confirm real sign-ins succeed | Sign-in logs + SAML trace | New key validated in prod | Silent breakage undetected |
| 6 | Remove the old certificate | Delete the inactive old cert | Metadata back to one key | Metadata bloat; old key lingers |
The single point of failure is step 2: if the SP cannot consume multiple keys (some legacy SPs pin exactly one), you have a hard cutover — schedule a window, then activate the new cert and update the SP’s single pinned key in lockstep. Know your SP’s capability before the cert nears expiry.
Inspect the keys and the active thumbprint via Graph — preferredTokenSigningKeyThumbprint names the active key, keyCredentials lists every key with startDateTime/endDateTime:
# Inspect signing keys/credentials, validity windows, and the active thumbprint
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>?\$select=keyCredentials,preferredTokenSigningKeyThumbprint"
Automate an expiry alert off keyCredentials.endDateTime — flag any credential within, say, 60 days of expiry:
# List each signing credential's expiry (ISO 8601) for alerting
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>?\$select=keyCredentials" \
--query "keyCredentials[?usage=='Sign'].{thumbprint:customKeyIdentifier, ends:endDateTime}" -o table
Set Notification Email Addresses to a monitored distribution list, never an individual. A silent expiry is a tenant-wide outage for that app with no warning to end users — just sudden signature-validation failures the day the cert lapses. Run the full 6-step runbook weeks ahead for a planned 3-year-expiry roll or a SHA-1→SHA-256 algorithm upgrade; on suspected key compromise stage and activate immediately, force the SP to re-ingest, and revoke the old key without waiting.
SCIM provisioning: closing the lifecycle gap
SAML logs users in; it never creates, updates, or removes accounts on the SP. SCIM 2.0 is the standard the SP exposes so Entra can push account lifecycle. Pairing SAML (authentication) with SCIM (provisioning) is what makes a federation complete rather than a login that leaves orphans behind. The division of labor:
| Concern | Handled by | Without it |
|---|---|---|
| Prove who the user is at login | SAML | No SSO |
| Create the account before/at first login | SCIM (or SP JIT) | Broken/under-provisioned account |
| Keep attributes in sync over time | SCIM | Stale data on the SP |
| Disable/delete on offboarding | SCIM (or runbook) | Orphaned account, license waste, security risk |
| Assign/revoke group membership on the SP | SCIM (groups) | Manual group management on the SP |
Where they overlap, the rules are: the NameID must equal the key SCIM provisions (or the assertion won’t match the SCIM-created account); authorization data (role/group) is best as a claim because it’s re-evaluated every login; profile data (name, email, dept) is best via SCIM to keep the SP record current; and account existence/status is SCIM-only, since only SCIM can create or disable. The JIT vs SCIM decision:
| Approach | How accounts appear | Deprovisioning | Best when |
|---|---|---|---|
| SP JIT off the assertion | Created on first SAML login from claims | None — leavers persist | SP has no SCIM; small/low-risk app |
| SCIM provisioning | Pushed by Entra ahead of login | Automatic on unassign/disable | SP supports SCIM; any real scale |
| SCIM + SAML | SCIM creates, SAML authenticates | Automatic | The complete pattern |
With JIT, the assertion is the only data at account creation, so every needed attribute must be a claim from day one — a missing claim means an under-privileged account that “logged in fine.” And because JIT has no deprovisioning, you must pair it with SCIM or a documented offboarding runbook, or leavers accumulate forever. Configure SCIM under the app’s Provisioning blade (tenant URL + secret token from the SP), map attributes, and scope to assigned users and groups. The lifecycle automation that feeds it is covered in Entra Lifecycle Workflows: Joiner–Mover–Leaver Automation.
Assign users and groups, and choose the NameID
Entra enterprise apps default to assignment required — only assigned users and groups get a token. Keep it that way unless the SP should genuinely be open to the whole tenant. Assignment also drives the “Groups assigned to the application” group-claim scope, so it does double duty.
# 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 — choose it deliberately
NameID is how the SP identifies the user across sessions; a wrong format or unstable source orphans accounts. In Attributes & Claims → Unique User Identifier (Name ID) pick a format and a source. The formats:
| NameID Format | Format URN (suffix) | Typical source | Stability | When to use |
|---|---|---|---|---|
| persistent | ...nameid-format:persistent |
user.objectid |
Durable — survives email/name change | SP supports it; the safe default |
| emailAddress | ...nameid-format:emailAddress |
user.mail / user.userprincipalname |
Breaks on mailbox/UPN change | SP keys on email and can’t do persistent |
| unspecified | ...nameid-format:unspecified |
Any | Depends on source | SP explicitly requires it |
| transient | ...nameid-format:transient |
Per-session pseudonym | New each session | Anonymous/privacy scenarios (rare) |
| windowsDomainQualifiedName | ...WindowsDomainQualifiedName |
DOMAIN\user |
AD-stable | Legacy SPs keyed on domain\user |
Pick the format the SP documents, and prefer a stable source. The failure mode is expensive: use user.mail for an SP that keys on it, and a single mailbox rename creates a duplicate account with none of the original’s data — and the original is now unreachable. Use persistent + user.objectid wherever the SP allows, since the object ID never changes for the life of the user. If the SP already keys accounts on a legacy value (sAMAccountName from before a UPN migration), pin NameID to that (user.onpremisessamaccountname) so existing keys stay stable — matching what the SP has beats the theoretically-best identifier.
The NameID source decision as a flow:
| If the SP… | Then set NameID | Because |
|---|---|---|
Supports persistent and has no legacy accounts |
persistent + user.objectid |
Most durable, immune to renames |
Already keys accounts on AD sAMAccountName |
persistent/unspecified + user.onpremisessamaccountname |
Preserves existing keys through UPN churn |
| Only accepts email as the key | emailAddress + user.userprincipalname |
UPN is more stable than mail; accept the rename risk |
Requires unspecified per its doc |
unspecified + the documented source |
Match the doc exactly |
Conditional Access for the SAML app
A SAML federation runs through Entra’s sign-in, so Conditional Access (CA) applies — you can require MFA, a compliant device, a sign-in frequency, or block legacy clients, scoped to the app’s service principal. It’s the right place to enforce assurance, and also where a policy correct tenant-wide can be wrong for one SAML app whose client can’t do an interactive redirect. The CA controls that matter for SAML apps:
| CA control | What it enforces | SAML-specific gotcha |
|---|---|---|
| Require MFA | Second factor at sign-in | Fails in non-interactive clients (kiosk WebView) → AADSTS50076 |
| Require compliant device | Intune-compliant device | Great alternative to MFA for managed kiosks |
| Require hybrid Azure AD join | Domain-joined + registered | Works for on-prem-managed endpoints |
| Sign-in frequency | Re-auth interval | Aggressive values re-prompt mid-shift |
| Block legacy authentication | Kills basic-auth protocols | Doesn’t apply to SAML front-channel itself |
| Authentication context | Step-up for sensitive ops | SP must request the context (many can’t) |
| Session controls (CAE) | Continuous access evaluation | Limited relevance to front-channel SAML |
Scope the policy to the SAML app’s service principal (Target resources → the enterprise app), not tenant-wide, so its requirements match what that app’s clients can satisfy. The AADSTS codes CA produces:
| AADSTS code | Meaning | Usual cause with SAML apps |
|---|---|---|
| AADSTS50076 | MFA required, interactive challenge needed | Client can’t render the MFA UI (WebView/kiosk) |
| AADSTS50079 | MFA registration required | User hasn’t set up MFA |
| AADSTS53000 | Device not compliant | Compliant-device grant not met |
| AADSTS53001 | Device not domain-joined | Hybrid-join grant not met |
| AADSTS50105 | User not assigned to the app | Assignment required, user not assigned |
| AADSTS53003 | Blocked by CA policy | A block policy matched |
The recurring lesson: validate CA against the real client, not a desktop browser. A kiosk WebView, a thick client embedding a browser control, or a server-to-server SP may be unable to do an interactive MFA redirect — so satisfy the assurance bar a different way, requiring a compliant device instead, for MFA-equivalent assurance with no challenge. Deeper CA design is in Conditional Access at Scale: Personas & Authentication Context; diagnosing blocks is in Troubleshooting Conditional Access: Sign-in Logs & Policy Blocks.
Architecture at a glance
Trace the federation as it flows and you have the whole mental model. A warehouse clerk opens the vendor billing app (the SP); with no session it builds a <samlp:AuthnRequest> and redirects to Entra ID (the IdP) over the HTTP-Redirect binding. Entra authenticates the clerk, then evaluates Conditional Access scoped to this app’s service principal (say, “require a compliant device” for the Intune-managed kiosk). Assurance satisfied, Entra assembles the assertion: it resolves the NameID from user.objectid in persistent format (the SP’s stable key), stamps Conditions with a short clock window and an AudienceRestriction equal to the app’s Entity ID, records the AuthnStatement, and builds the AttributeStatement from your Attributes & Claims — emailaddress, a role from a conditional claim, and an app-assigned group claim of Group IDs. Entra signs the assertion with this app’s token signing certificate (SHA-256) and POSTs the <samlp:Response> to the SP’s ACS / Reply URL.
The trust then flips to the SP. Having fetched Entra’s app-specific federation metadata (?appid=) in advance, it holds the signing key: it validates the signature, checks the audience equals its Entity ID, confirms the time is inside the window, reads the NameID to locate (or JIT-create) the account, applies role and group claims for authorization, honours the RelayState deep-link, and drops a session cookie. Out of band, a SCIM job from the same enterprise app has already provisioned this user — so the assertion’s NameID matches an account SCIM created, and when the clerk leaves SCIM disables it. Every arrow maps to a setting here: the POST to the Reply URL, the audience to the Identifier, the signature to the certificate, the attributes to Attributes & Claims, the lifecycle to SCIM, the assurance to Conditional Access. When it breaks, localize to one arrow — Entra refused (AADSTS in sign-in logs) versus the SP rejected (signature/audience/NameID in a SAML-tracer capture) — and fix that one field. The convergence point for diagnosis is one artifact: the assertion POSTed to the ACS. Capture it, compare Issuer, audience, NameID, each attribute, and NotOnOrAfter against the SP’s requirements, and fix the matching setting.
Real-world scenario
Meridian Logistics federated a legacy warehouse-management system (WMS) from vendor Cargobase over SAML so 1,400 staff across 22 sites could SSO instead of sharing a local admin password. The WMS ran on-prem behind a reverse proxy; per its 40-page integration PDF it keyed accounts on sAMAccountName, required role and site attributes under bare (namespace-less) names, validated the assertion signature (not the response), and could ingest only a single signing certificate. Staff logged in from Intune-managed kiosks running the WMS inside an embedded WebView with no cookie persistence.
The pilot (one site, 60 users) worked; then two things broke production. First, a tenant-wide Conditional Access change forced interactive MFA — the kiosk WebView couldn’t render the challenge, so every request died on AADSTS50076, plain in the sign-in logs filtered to the Cargobase service principal. Second, a concurrent domain migration changed everyone’s UPN; the NameID had (correctly at pilot) been sourced from user.userprincipalname, but the new UPN no longer matched the sAMAccountName Cargobase had on file, so even users who could authenticate landed on empty JIT accounts.
The fix had three parts. One: a CA policy scoped to the Cargobase service principal requiring a compliant device instead of interactive MFA — the strong-auth bar without a challenge the WebView couldn’t do. Two: re-pin the NameID to the stable on-prem attribute so it survived the UPN churn:
Unique User Identifier (Name ID)
Format: persistent
Source attribute: user.onpremisessamaccountname
Three: because Cargobase JIT-provisioned with no deprovisioning, add the missing site claim (a Regex Replace pulling the four-digit code from user.extensionattribute7 = "SITE-0427-EU" → 0427) so JIT accounts were created complete, and stand up a monthly offboarding runbook (Cargobase had no SCIM endpoint) to disable leavers.
They validated by capturing a real assertion with SAML-tracer from an actual kiosk (not a desktop browser — the whole point), confirming: signature over the <Assertion> element (a response-level signature would have been silently rejected), NameID equal to the historical sAMAccountName, and both role and site present with bare names. Rollout to all 22 sites then succeeded. The takeaways: a CA policy correct tenant-wide can be wrong for one SAML app whose client can’t do an interactive redirect — scope per app and test on the real client; and NameID must match what the SP already has, not what’s theoretically best — a UPN migration is exactly when an unstable NameID bites.
The incident and fix as a timeline, because the order of discovery is the lesson:
| Phase | Symptom | Diagnosis | Fix |
|---|---|---|---|
| Pilot | Works for 60 users | — | NameID = user.userprincipalname |
| Prod day 1 | All logins fail from kiosks | Sign-in logs: AADSTS50076 (MFA in WebView) | CA → compliant device, scoped to the SP |
| Prod day 1 | Authenticated users get empty accounts | UPN migration broke sAMAccountName match |
NameID → persistent + user.onpremisessamaccountname |
| Prod day 3 | New accounts miss site permissions | JIT account lacked a site claim |
Regex Replace claim site from extensionattribute7 |
| Ongoing | Leavers persist on the SP | No SCIM on Cargobase | Monthly offboarding runbook |
Advantages and disadvantages
SAML with a custom enterprise app is the right tool for a specific, common job — and the wrong tool if a modern alternative exists. Weigh it honestly:
| Advantages | Disadvantages |
|---|---|
| Universally supported by legacy/enterprise SPs — often the only protocol they speak | Verbose XML with front-channel-only trust; harder to debug than a JWT |
| Rich, flexible claims: transformations, conditional claims, directory extensions | Claim mapping is fiddly; exact names/namespaces required or the app silently breaks |
| Entra manages signing keys and metadata; per-app certs and rollover are built in | Certificate rollover is a coordination dance; a silent expiry is a tenant-wide outage |
| Conditional Access applies transparently — MFA/device/session on any SAML app | CA correct tenant-wide can block a client that can’t do an interactive redirect |
| Group and role claims drive SP authorization from the directory | Group-claim overage (150 for SAML) silently drops the claim past the cutoff |
| No secrets in the app for SSO — trust is a published certificate | No deprovisioning — SAML never removes leavers; you must add SCIM or a runbook |
| Mature, stable spec — behavior is predictable across IdPs | IdP-initiated flow is replay-prone; NameID choices orphan accounts if unstable |
SAML is right when the SP only speaks SAML — the norm for vendor and legacy apps — and you need directory-driven authorization and CA-governed assurance. It is wrong when the app is modern and could use OIDC; reach for OIDC & OAuth 2.0 Flows in Entra ID there. The disadvantages are all manageable — overage by scoping to app-assigned groups, cert expiry by alerting off keyCredentials, orphans by adding SCIM, WebView-MFA by device compliance — but only if you know they exist, which is the point of this article.
Hands-on lab
Federate a real, free SAML SP against your tenant, map claims, and inspect the assertion — then tear it down. We use SAMLtest.id, a public SAML SP test service, as the “vendor app,” and drive Entra from the portal plus a few az calls. A test/dev tenant is fine; nothing here incurs cost.
Step 1 — Create the non-gallery enterprise app.
In the portal: Entra ID → Enterprise applications → New application → Create your own application → Non-gallery, name it lab-samltest, create. Or via CLI:
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": "lab-samltest"}'
# Note the returned application.id and servicePrincipal.id
Step 2 — Set the SAML mode and the two contract fields. In Single sign-on → SAML → Basic SAML Configuration → Edit set Identifier (Entity ID) https://samltest.id/saml/sp and Reply URL (ACS) https://samltest.id/idp/profile/SAML2/POST/ACS. Expected: both values saved, no validation error.
Step 3 — Add a custom claim and a transformation. In Attributes & Claims → Add new claim, create preferred_username with Transformation source ExtractMailPrefix(user.userprincipalname); the inline evaluator should turn alice@yourtenant.onmicrosoft.com into alice. Save.
Step 4 — Set a stable NameID. In Attributes & Claims → Unique User Identifier (Name ID), set Format = persistent, Source attribute = user.objectid. Save.
Step 5 — Assign yourself and confirm assignment is required.
# Confirm assignment required (default true); assign your own user
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>?\$select=appRoleAssignmentRequired"
ME=$(az ad signed-in-user show --query id -o tsv)
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/users/$ME/appRoleAssignments" \
--headers "Content-Type=application/json" \
--body "{\"principalId\":\"$ME\",\"resourceId\":\"<sp-object-id>\",\"appRoleId\":\"00000000-0000-0000-0000-000000000000\"}"
Expected: appRoleAssignmentRequired: true; the assignment POST returns 201.
Step 6 — Hand Entra’s metadata to the SP. Copy the App Federation Metadata Url (it contains ?appid=) and register your IdP on SAMLtest.id by that URL via its “Upload Metadata” page — the “give the SP your metadata” direction.
Step 7 — Run SP-initiated login and capture the assertion. Install SAML-tracer, start SP-initiated login from SAMLtest.id’s “Test Your IdP” flow, authenticate as your assigned user, and open the POST to the ACS in SAML-tracer’s SAML tab. Expected in the assertion: <Audience>https://samltest.id/saml/sp</Audience>, a <NameID Format="...persistent"> with your object ID, and <Attribute Name="preferred_username"> equal to your UPN’s local part (SAMLtest.id also renders the attributes on its result page).
Step 8 — Decode an assertion offline (when you only have the raw POST).
# HTTP-POST binding: SAMLResponse is base64 (URL-encoded), NOT deflated
python3 -c "import sys,urllib.parse,base64; print(base64.b64decode(urllib.parse.unquote(sys.argv[1])).decode())" '<SAMLResponse-form-value>'
Expected: the raw XML prints, letting you eyeball Issuer, audience, NameID, and attributes with no tool.
Validation checklist. You created a non-gallery SAML app, set the audience and ACS to a real SP, mapped a transformed claim and a stable NameID, required and granted assignment, exchanged metadata, ran a real SP-initiated login, and read the assertion two ways. Each step maps to a real production task:
| Step | What you did | What it proves | Real-world analogue |
|---|---|---|---|
| 2 | Set Entity ID + ACS | The URL contract is exact | First-day federation setup |
| 3 | Transformation claim | Claims can be reshaped | Matching a picky SP attribute |
| 4 | persistent + objectid NameID |
Stable account key | Preventing orphaned accounts |
| 5 | Assignment required + assign | Least-privilege access | Locking a SAML app to a group |
| 7 | Capture the assertion | The diagnostic artifact exists | The 5-minute SAML diagnosis |
| 8 | Offline decode | You can read any assertion | Mid-incident, no extension installed |
Cleanup. Delete the enterprise app (removes the service principal and its config):
az rest --method DELETE --url "https://graph.microsoft.com/v1.0/servicePrincipals/<sp-object-id>"
az rest --method DELETE --url "https://graph.microsoft.com/v1.0/applications/<app-object-id>"
No lingering cost — enterprise apps and app registrations are free; only the directory objects existed.
Common mistakes & troubleshooting
The playbook you bookmark. Work from cheapest signal to most detailed: portal Test button → sign-in logs (AADSTS) → SAML-tracer capture → offline decode. First the scannable table, then expanded reasoning for the entries that bite hardest.
| # | Symptom | Root cause | Confirm (exact path / command) | Fix |
|---|---|---|---|---|
| 1 | Sign-in fails with a reply-URL error | Reply URL (ACS) mismatch — path/trailing slash/scheme | Sign-in logs → AADSTS50011 (names sent vs registered URL) | Add the SP’s exact ACS URL (scheme+host+path) to Reply URLs |
| 2 | “You are not assigned” / login refused | User not assigned; assignment required | Sign-in logs → AADSTS50105 | Assign the user/group, or relax appRoleAssignmentRequired |
| 3 | Assertion arrives but SP rejects it, no AADSTS | Signing option mismatch (signed assertion, SP validates response) | SAML-tracer: signature present but on wrong element; SP logs | Match Signing Option to SP (assertion vs response vs both) |
| 4 | SP says “audience invalid” | Entity ID ≠ SP’s expected audience | Decode assertion → <Audience> vs SP config |
Set Identifier to the SP’s exact Entity ID (byte-for-byte) |
| 5 | Login works, app shows no permissions | Required claim missing or mis-named | Decode assertion → AttributeStatement lacks the claim / wrong Name | Add the claim with the SP’s exact Name + namespace |
| 6 | Permissions vanish for high-group users | Group-claim overage (>150 for SAML) | Assertion has a groups overage link instead of groups |
Scope to app-assigned groups; filter; or emit app roles |
| 7 | All logins fail after a date | Cert rolled/expired; SP has old key | Sign-in logs show success, SP rejects signature; check cert Status/expiry | Stage+publish new key, SP re-ingests metadata, then activate |
| 8 | Duplicate/empty account on the SP | Unstable NameID (email/UPN) changed | Assertion NameID differs from the SP’s stored key | Use persistent + user.objectid / stable on-prem attr |
| 9 | Fails only in a kiosk/WebView, browsers pass | CA MFA in a non-interactive client | Sign-in logs → AADSTS50076/50079 | Require compliant device instead of interactive MFA |
| 10 | “Assertion expired” / not-yet-valid | Clock skew on the SP vs Entra’s short window | Decode NotBefore/NotOnOrAfter; check SP server time |
Fix SP NTP/time; don’t widen Entra’s window |
| 11 | Empty claim value for some users | Source attribute null (e.g. user.mail unlicensed) |
Decode assertion: attribute present but empty | Source from a guaranteed-present attr (UPN/objectid) |
| 12 | Misconfigured-app error at Entra | Bad identifier/claims/consent config | Sign-in logs → AADSTS650056 | Re-check identifier, claims, and admin consent |
| 13 | Regex claim emits nothing | Regex didn’t match the sample | Claim editor evaluator returns empty | Fix the regex; test in the inline evaluator before saving |
| 14 | IdP-init login rejected by the SP | SP doesn’t accept unsolicited assertions | SP logs “no InResponseTo” / unsolicited rejected | Use SP-init (populate Sign-on URL) or enable IdP-init on the SP |
| 15 | Group claim shows GUIDs, SP wanted names | Wrong group-claim value chosen | Decode assertion: groups are objectIds | Switch group claim value to sAMAccountName/display name (or map on SP) |
| 16 | Auth loops / re-prompts every few minutes | Sign-in frequency too aggressive in CA | CA policy session control; sign-in logs show repeated interactive auth | Relax sign-in frequency for the app’s persona |
The expanded reasoning for the four that cause the most wasted hours:
Signing-option mismatch — the “valid but rejected” trap (row 3). The assertion looks perfect in SAML-tracer, the signature is valid, and the SP still rejects it with no AADSTS code because Entra did what it was told. The SP validates the response signature; you only signed the assertion. Note which element carries <ds:Signature> in the trace, compare to the SP’s requirement, and set the Signing Option to match.
Group-claim overage — the silent permission loss (row 6). A user in more than 150 groups (SAML) gets no groups claim; Entra emits a groups overage indicator (a Graph URL the SP can’t follow) and group-derived permissions disappear with a Success status. Decode the assertion: a groups claim whose value is a URL (not GUIDs/names) is overage. Fix by scoping to Groups assigned to the application, filtering, or migrating to app roles.
Unstable NameID orphaning accounts (row 8). NameID sourced from user.mail/user.userprincipalname changes (mailbox rename, UPN migration), so the SP no longer finds the existing account and JIT-creates an empty duplicate or denies access. Compare the assertion’s NameID to what the SP stored; fix with persistent + user.objectid, or pin to the legacy key (user.onpremisessamaccountname) the SP already uses.
MFA in a non-interactive client (row 9). A kiosk WebView or thick-client browser control can’t render the MFA challenge, so CA-required interactive MFA fails there while desktop browsers pass — sign-in logs filtered to the app show AADSTS50076 on the kiosk client. Scope a CA policy requiring a compliant device instead of interactive MFA — equivalent assurance, no challenge.
Best practices
- Prefer SP-initiated; disable IdP-initiated unless the SP requires it — leave the Sign-on URL blank to force the more secure, correlatable flow.
- Set NameID to
persistent+user.objectidwherever the SP allows; match a legacy key (user.onpremisessamaccountname) only when the SP already stores accounts on it, and never key onuser.mail. - Confirm every claim Name and namespace against the SP’s doc and validate against a live assertion — a mis-named required claim breaks the app silently.
- Scope the group claim to “Groups assigned to the application,” or use app roles — bounds the set well under the 150-group overage cutoff and keeps authorization intentional.
- Match the Signing Option to what the SP validates, and prefer SHA-256 — the number-one “valid-but-rejected” cause; confirm in a trace.
- Set the certificate notification email to a monitored DL and alert off
keyCredentials.endDateTime— a silent expiry is a tenant-wide outage with no user-facing warning. - Practice the zero-downtime rollover before you need it and know your SP’s key capability — single-key SPs need a scheduled lockstep cutover.
- Keep assignment required and assign via groups — least privilege, and it doubles as the app-assigned-groups scope for the group claim.
- Pair SAML with SCIM (or a documented offboarding runbook) — SAML never deprovisions, so without it leavers persist forever.
- Scope Conditional Access to the app’s service principal and test on the real client — a tenant-wide policy can be wrong for a client that can’t do an interactive redirect.
- Hand the SP the
?appid=app-specific metadata URL, never the tenant-wide one — it pins the document to this app’s certificate(s) and makes per-app rollover work. - Manage the app, SP, claims policy, and certificate as code (Terraform
azuread) — a reviewed IaC change beats a portal click for a contract this brittle.
Security notes
- Trust is the published certificate — protect the metadata and the SP’s ability to re-fetch it. The SP validates every assertion against the key in your
?appid=metadata; substitute the metadata and you substitute the signing key. Serve/consume over HTTPS and pin the app-specific URL. - Enforce assurance with Conditional Access, not by trusting the SP. Require MFA or a compliant device at the IdP — for federated apps the SP does no authentication of its own.
- Keep NameID and claims minimal and non-sensitive. The assertion travels through the user’s browser; emit only what the SP needs, no secrets or unnecessary PII.
- Prefer SP-initiated to reduce replay surface. IdP-initiated assertions are unsolicited and lack
InResponseTo; if you must allow them, ensure the SP enforces the short lifetime and a replay cache. - Rotate signing certificates immediately on suspected compromise. Stage and activate a new key, force the SP to re-ingest, retire the old — don’t wait for the 3-year expiry.
- Least-privilege the SCIM connection and the app’s permissions. The SCIM token can create/disable accounts on the SP; store it securely and rotate it. Grant the app only the assignment scope it needs.
- Encrypt the assertion when the SP supports it and claims are sensitive. If the SP publishes an encryption certificate, enable assertion encryption so values aren’t readable in a browser-side capture.
- Audit sign-ins and provisioning to your SIEM. A spike in AADSTS50011/50076 or provisioning failures is an early signal of a broken or attacked integration.
Cost & sizing
SAML SSO has no per-app licensing cost — enterprise apps, app registrations, claims mapping, and signing certificates are free with any Entra tenant. The costs are licensing tier and operational, not per-federation:
| Item | Cost driver | Rough figure | Notes |
|---|---|---|---|
| Enterprise app + SAML SSO | None | ₹0 | Unlimited SAML apps on any tenant |
| Conditional Access | Entra ID P1 per user | ~₹500–650/user/month (P1) | CA requires P1; needed to govern the SAML app’s assurance |
| Identity Protection (risk-based CA) | Entra ID P2 per user | ~₹750–950/user/month (P2) | Only if you add risk-based policies |
| SCIM provisioning | Included with the app | ₹0 (Entra side) | The SP may charge per provisioned seat |
| SP-side licensing | Per the vendor | Varies | Orphaned accounts = wasted SP licenses — the real cost of no deprovisioning |
| Engineering time | Setup + rollover + troubleshooting | The dominant cost | Table-driven runbooks cut this sharply |
The sizing questions are operational, not compute: there is no hard limit on SAML apps (codify them in Terraform rather than hand-click at scale); P1 is required for any user hitting a CA-governed app; keep groups-per-user under the 150 cutoff by scoping to app-assigned groups or app roles; and deprovisioning via SCIM reclaims leaver seats. The dominant cost is engineering time — a broken federation burns an architect’s day and stalls a department’s access; the table-driven runbooks here compress that to a per-field diagnosis in minutes. The second is orphaned SP licenses without deprovisioning — for a per-seat SaaS SP, a few hundred un-removed leavers is a recurring bill SCIM eliminates.
Interview & exam questions
1. Difference between SP-initiated and IdP-initiated SAML SSO, and which is more secure? SP-initiated starts at the app, which sends an <AuthnRequest>; Entra POSTs a <Response> the SP correlates via InResponseTo. IdP-initiated starts at My Apps and POSTs an unsolicited assertion with nothing to correlate. SP-initiated is more secure — the correlation closes replay/injection issues, and many SPs reject unsolicited assertions. Prefer SP-init; leave the Sign-on URL blank to disable IdP-init.
2. What four things must an SP validate in an Entra assertion? The signature (over the correct element per its config), the audience (AudienceRestriction = its Entity ID), the time window (NotBefore/NotOnOrAfter), and the NameID (right format, maps to an account). Missing any one rejects an otherwise-valid assertion; the top two failures are wrong Entity ID and SP clock skew.
3. An assertion looks perfect in SAML-tracer but the SP rejects it with no AADSTS error. Cause? A signing-option mismatch — the SP validates the response signature but Entra signed only the assertion (or vice versa). Entra logs success because it did what it was told, so no AADSTS code. Note which element carries <ds:Signature> in the trace versus the SP’s requirement, and set the Signing Option to match.
4. What is group-claim overage and how do you prevent it? Past 150 groups (SAML cutoff) Entra omits the groups claim and emits a groups overage indicator — a Graph URL most SAML SPs can’t follow — so group-derived permissions silently vanish. Prevent it by scoping to Groups assigned to the application, filtering groups, or emitting app roles (not subject to the cutoff).
5. Why is NameID choice critical, and what’s the safest configuration? NameID is the SP’s primary key; an unstable one (email/UPN) that later changes orphans the account or creates a duplicate. Safest is persistent + user.objectid, which never changes. Where the SP already keys on a legacy value (sAMAccountName from before a UPN migration), pin NameID to that exact attribute so existing keys stay stable.
6. Describe the zero-downtime signing-certificate rollover. Create a new cert (inactive; the old one keeps signing), have the SP re-ingest the ?appid= metadata so it trusts both keys, confirm, then activate — SPs that picked up both keys keep validating. Confirm real sign-ins, then remove the old cert. A single-key SP needs a scheduled lockstep cutover instead.
7. An app JIT-provisions off the assertion with no SCIM. What two risks? Under-provisioning — the assertion is the only data at account creation, so any missing claim (email, name, role) yields a malformed account — and no deprovisioning — SAML never signals “this user left,” so leavers persist as active accounts and licenses forever. Mitigate by mapping every needed attribute and pairing with SCIM or an offboarding runbook.
8. Logins fail only from Intune kiosks (WebView), not desktop browsers. Diagnose and fix. Sign-in logs show AADSTS50076 — a CA policy requires interactive MFA the embedded WebView can’t render. Fix by scoping a CA policy to the app’s service principal that requires a compliant device instead of interactive MFA, giving equivalent assurance without a browser challenge.
9. How do transformations and conditional claims differ? A transformation reshapes a value via string functions (e.g. ExtractMailPrefix, or Regex Replace for a site code) — use it when the raw attribute isn’t the shape the SP wants. A conditional claim emits different values by user type or group, last-match-wins — use it when role must be admin for one group, a title for members, and partner-readonly for guests.
10. When choose SAML over OIDC, and vice versa? Choose SAML when the SP only speaks it (common for legacy/vendor apps) and you need directory-driven claims and CA-governed assurance. Choose OIDC for modern apps — simpler JWTs, back-channel validation against a JWKS, refresh tokens, better SPA/mobile support. Don’t adopt SAML for greenfield that could use OIDC.
These map primarily to SC-300 (Identity and Access Administrator) — implement and manage enterprise app SSO, claims, and Conditional Access — and AZ-500 (Security Engineer) — manage identity and application access. A compact cert mapping:
| Question theme | Primary cert | Objective area |
|---|---|---|
| SAML flows, assertion validation | SC-300 | Plan/implement app SSO |
| Claims, transformations, conditional claims | SC-300 | Configure enterprise app claims |
| Group claims / overage / app roles | SC-300 | Manage app authorization |
| Certificate rollover | SC-300 / AZ-500 | Manage app credentials |
| Conditional Access for the app | SC-300 / AZ-500 | Implement CA policies |
| SCIM / lifecycle | SC-300 | Provision app access |
Quick check
- A SAML login fails and the sign-in logs show AADSTS50011. What field is wrong, and what exactly do you check?
- An assertion validates in SAML-tracer (signature present and valid) but the SP rejects it with no AADSTS code. What is the single most likely cause?
- A user who belongs to 300 groups suddenly loses all their app permissions, but login succeeds. What happened, and what’s the fix?
- You must pick a NameID for an SP that already keys accounts on
sAMAccountName(from before a UPN migration). What format and source do you choose, and why? - You need to roll the token signing certificate for an app whose SP can consume multiple keys. Give the ordered steps for zero downtime.
Answers
- The Reply URL (ACS) doesn’t match what the SP sent — a path difference, trailing slash, or
http/httpsmismatch. The AADSTS50011 error page in the sign-in logs prints the URL that was attempted versus what’s registered; add the SP’s exact ACS URL (scheme + host + path) to the Reply URLs. - A signing-option mismatch — the SP validates the response signature but Entra signed only the assertion (or vice versa). Entra logs a clean success because it did what it was configured to do; match the Signing Option (assertion / response / both) to the SP’s documented requirement.
- Group-claim overage: past the SAML cutoff of 150 groups, Entra drops the groups claim and emits a
groupsoverage link the SP can’t follow, so group-derived permissions vanish with aSuccessstatus. Fix by scoping the group claim to Groups assigned to the application, filtering groups, or emitting app roles instead. persistentformat with sourceuser.onpremisessamaccountname. The SP already stores accounts keyed on the historicalsAMAccountName, so pinning NameID to that stable on-prem attribute preserves existing account keys through the UPN churn — matching what the SP has beats the theoretically-idealuser.objectidhere.- (1) Create a new certificate — it’s inactive; the old one keeps signing. (2) Have the SP re-ingest the
?appid=metadata so it trusts both public keys. (3) Confirm the SP shows both keys. (4) Activate the new certificate. (5) Confirm real sign-ins succeed (logs + trace). (6) Remove the old certificate.
Glossary
- SAML 2.0 — an OASIS standard for exchanging signed authentication and authorization assertions in XML, most commonly used for browser-based web SSO.
- IdP (Identity Provider) — the party that authenticates the user and issues the assertion; here, Entra ID.
- SP (Service Provider) — the application that consumes the assertion and grants access; your non-gallery app.
- AuthnRequest — the
<samlp:AuthnRequest>the SP sends to the IdP to start SP-initiated SSO; travels DEFLATE-compressed on the HTTP-Redirect binding. - Response — the
<samlp:Response>envelope the IdP POSTs to the SP’s ACS; carries the status and the assertion; may be signed. - Assertion — the
<saml:Assertion>inside the response — the signed statement about the user (Subject, Conditions, AuthnStatement, AttributeStatement). - NameID — the
<Subject>identifier that is the SP’s primary key for the user; chosen by format (persistent/emailAddress/etc.) and source attribute. - AudienceRestriction — the
<Conditions>element naming who the assertion is for; must equal the SP’s Entity ID. - ACS (Assertion Consumer Service) / Reply URL — the SP endpoint where the IdP POSTs the assertion; HTTPS, matched exactly; mismatch → AADSTS50011.
- Entity ID / Identifier — the unique name of the SP (URN or URL) that becomes the assertion’s audience; matched byte-for-byte.
- Sign-on URL — the SP login endpoint where SP-initiated flow begins; populating it also enables the IdP-initiated tile in My Apps.
- RelayState — an opaque value the SP uses as a post-login deep link; round-tripped by the IdP unchanged.
- Attributes & Claims — the enterprise-app configuration that defines which claims the assertion carries, their names/namespaces, and their sources.
- Transformation — a claim source that derives a value via string functions (ExtractMailPrefix, Join, case, Regex Replace) over one or two inputs.
- Conditional claim — a claim whose value differs by user type or group membership, evaluated last-match-wins.
- Group claim / overage — a claim carrying group membership; past 150 groups (SAML) Entra drops it and emits an overage link, silently removing group-based permissions.
- App role — a curated role defined on the app that users/groups are assigned to and that is emitted as a claim; sidesteps the group overage cutoff.
- Token signing certificate — the per-app certificate Entra signs assertions with (default 3-year validity); rolled via a stage/publish/activate/retire runbook.
- Signing option — whether Entra signs the assertion, the response, or both; must match what the SP validates.
?appid=federation metadata — the app-specific IdP metadata URL, pinned to this app’s certificate(s); hand this to the SP so per-app rollover works.- SCIM — System for Cross-domain Identity Management; the protocol Entra uses to provision, update, and deprovision accounts on the SP, closing SAML’s lifecycle gap.
- AADSTS code — Entra’s sign-in error identifier surfaced in the sign-in logs (e.g. 50011 reply-URL, 50105 unassigned, 50076 MFA-interactive); names the Entra-side failure directly.
- SAML-tracer — a browser extension that captures the SAML messages (including the assertion POSTed to the ACS) so you can decode and validate every field.
Next steps
You can now federate a non-gallery SAML app end to end and diagnose any failure from an AADSTS code and a captured assertion. Build outward:
- Next: Entra ID Token Claims, App Roles & the On-Behalf-Of Flow — replace raw group claims with curated app roles and sidestep overage for good.
- Related: Configuring SAML SSO for an Enterprise App: The Configuration Guide — the happy-path walkthrough, if you want the shorter version to hand a colleague.
- Related: OIDC & OAuth 2.0 Flows in Entra ID: Authorization Code + PKCE — the modern alternative to reach for when the app isn’t SAML-only.
- Related: Conditional Access at Scale: Personas & Authentication Context — govern the SAML app’s assurance without breaking non-interactive clients.
- Related: Troubleshooting Conditional Access: Sign-in Logs & Policy Blocks — read the sign-in logs like an expert when a policy blocks the federation.
- Related: Entra Lifecycle Workflows: Joiner–Mover–Leaver Automation — the automation that feeds SCIM so leavers actually get removed from the SP.