Identity Azure

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

Most “SSO is broken” tickets for SAML apps trace back to three things, 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:

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/&lt;tenant-id&gt;/</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/&lt;tenant-id&gt;/</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
Email 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 (extensionAttribute115) that sync via Entra Connect and appear as user.extensionattribute115. 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.comjane.doe SP keys on the username, not full email
Join() Concatenate two values with a separator Sales + ManagerSales-Manager Composite role/group strings
ToLowercase() Lower-case the value Jane.Doejane.doe Case-sensitive SP matching
ToUppercase() Upper-case the value usUS 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 4402CC-4402 Namespacing a code
RegexReplace() .NET regex with named groups → output pattern SITE-0427-EU0427 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.comExtractMailPrefix()Jane.DoeToLowercase()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 & Claimsemailaddress, 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

Security notes

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

  1. A SAML login fails and the sign-in logs show AADSTS50011. What field is wrong, and what exactly do you check?
  2. 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?
  3. A user who belongs to 300 groups suddenly loses all their app permissions, but login succeeds. What happened, and what’s the fix?
  4. 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?
  5. 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

  1. The Reply URL (ACS) doesn’t match what the SP sent — a path difference, trailing slash, or http/https mismatch. 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.
  2. 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.
  3. Group-claim overage: past the SAML cutoff of 150 groups, Entra drops the groups claim and emits a groups overage link the SP can’t follow, so group-derived permissions vanish with a Success status. Fix by scoping the group claim to Groups assigned to the application, filtering groups, or emitting app roles instead.
  4. persistent format with source user.onpremisessamaccountname. The SP already stores accounts keyed on the historical sAMAccountName, 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-ideal user.objectid here.
  5. (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

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:

Entra IDSAMLSSOEnterprise AppClaims MappingConditional AccessSCIMSC-300
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading