TL;DR

SAML spec allows a <NameID> to be empty or null in certain assertions. If your SP doesn’t validate the issuer or enforce a non-null NameID, an attacker can send a signed assertion from any IdP and — depending on configuration — be mapped to any user. This isn’t theoretical. It’s been exploited in production across Okta, Azure AD, and Ping Identity for years. The fix is three lines in your SP config.

The Vulnerability

SAML is an XML protocol. When an IdP issues an assertion, it includes a <NameID> element identifying the user. The SAML 2.0 spec defines several NameID formats — emailAddress, transient, persistent, unspecified, and the empty/null case.

The null case is the problem:

<saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified">
</saml2:NameID>

Or even shorter:

<saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified"/>

Both are valid SAML 2.0. The SP must handle them — but many don’t validate that NameID is non-empty before mapping to a local account.

How Exploitation Works

  1. Get an IdP you control. Any free SAML IdP works — SimpleSAMLphp on a VPS, Keycloak in a container, even Authelia.
  2. Issue a signed assertion for user@example.com with a null or empty NameID.
  3. Send it to your SP. If the SP doesn’t validate NameID content, it falls back to another claim (email in the assertion, or a default mapping).
  4. Map to any user. If your SP uses the SAML attribute email for account lookup, and the IdP sends email: admin@example.com, the admin gets authenticated — even though the NameID was null.

The exploit chain is:

Attacker's IdP → signed assertion → SP → account mapping → authenticated as target

The key insight: NameID is just one claim among many in a SAML assertion. If the SP maps from a different claim (email, uid, subject), the NameID value doesn’t matter. A null NameID becomes invisible in the chain.

The Three Fixes

1. Enforce non-null NameID

In your SP config (Shibboleth example):

<ApplicationDefaults entityID="https://sp.example.com">
  <Sessions nameIDFormat="transient,persistent,emailAddress"/>
  <Sessions handlerSSL="true" cookieProps="https"/>
  <AttributeExtractor type="XML"/>
  <!-- This is the critical line -->
  <SSO entityID="https://idp.example.com"
       nameIDFormat="emailAddress"
       requireNameID="true"/>
</ApplicationDefaults>

requireNameID="true" — but this only checks presence, not content. Add a validation rule:

<AttributeResolver type="Query" subjectMatch="true">
  <Query>
    <Attribute name="email"
               sourceEntity="https://idp.example.com"
               attributeName="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"/>
  </Query>
</AttributeResolver>

2. Validate the issuer

Many SAML SPs default to trusting assertions from any IdP that presents a valid signature. If you run multiple IdPs (or accept federated IdPs from partner orgs), this means:

<MetadataProvider type="XML" uri="https://idp.example.com/metadata.xml"/>
<MetadataProvider type="XML" uri="https://partner.example.com/metadata.xml"/>

If an attacker controls https://partner.example.com (a common scenario — partner orgs often have looser security), they can issue assertions trusted by your SP.

Fix: Enforce a whitelist of allowed issuers:

<SSO entityID="https://idp.example.com"
     signResponse="true"
     signAssertion="true"
     wantAssertionSigned="true"/>

3. Match NameID to a local account, not just any claim

The most common mapping logic in SPs is:

If email claim exists → find local account with matching email

This means an attacker can send any email in the assertion, regardless of NameID. A better approach:

NameID (primary) → local account
email claim (secondary, fallback only if NameID matches known IdP)

In practice:

# Bad mapping
def find_user(saml_response):
    email = saml_response.get('email')
    return User.objects.get(email=email)  # NameID ignored

# Good mapping
def find_user(saml_response):
    nameid = saml_response.get('nameid')
    if nameid and nameid.strip():
        return User.objects.get(saml_nameid=nameid)
    # Fallback: only if issuer is trusted
    issuer = saml_response.issuer
    if issuer in TRUSTED_ISSUERS:
        email = saml_response.get('email')
        return User.objects.get(email=email)

How to Test

Use saml-tracer (Firefox/Chrome extension) or xmlsec1 to craft a test assertion:

# Extract the IdP's public key from metadata
xmlsec1 --idp-cert --extract --output idp-cert.pem \
    https://idp.example.com/metadata.xml

# Create a minimal signed assertion with null NameID
cat > assertion.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
    Destination="https://sp.example.com/saml/acs"
    ID="_test123" IssueInstant="2026-09-16T12:00:00Z" Version="2.0">
  <saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
      ID="_assert123" IssueInstant="2026-09-16T12:00:00Z" Version="2.0">
    <saml2:Issuer>https://idp.example.com</saml2:Issuer>
    <saml2:Subject>
      <saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified"/>
      <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml2:SubjectConfirmationData Recipient="https://sp.example.com/saml/acs"/>
      </saml2:SubjectConfirmation>
    </saml2:Subject>
    <saml2:AttributeStatement>
      <saml2:Attribute Name="email">
        <saml2:AttributeValue>admin@example.com</saml2:AttributeValue>
      </saml2:Attribute>
    </saml2:AttributeStatement>
  </saml2:Assertion>
</saml2p:Response>
EOF

# Sign it
xmlsec1 --sign --privkey-pem idp-key.pem,idp-cert.pem \
    --output signed-assertion.xml assertion.xml

Send the signed assertion via POST to your SP’s ACS endpoint. If you land in as admin@example.com — you’ve found the vulnerability.

Why It’s Still Common in 2026

  1. IdP-centric design. Most SAML tutorials teach you to configure the IdP. The SP config is an afterthought, often copy-pasted from vendor docs.
  2. Default-to-trust. The SAML spec’s default behavior is trust everything with a valid signature from a known IdP. This is convenient but permissive.
  3. Email-based mapping. The email claim is human-readable and universally understood by developers. It’s also attacker-controlled in most IdP configurations.
  4. Testing gaps. SAML tests usually verify happy-path login flows. Null NameID, missing issuer validation, and claim-overwrite all slip through because they require crafted assertions, not standard IdP interactions.

Trade-offs

  • Stricter NameID validation may break IdPs that send unspecified format (some legacy IdPs do this). Test before deploying.
  • Issuer whitelisting requires maintaining a list of trusted IdP metadata URLs. Automation (periodic fetch/refresh) is worth the effort.
  • Email fallback adds a timing window: if two users share an email across IdPs, the first IdP to respond wins. Acceptable for most orgs.

References