SAML service provider metadata: what it contains and why it matters

When an enterprise IT administrator sets up SSO between their identity provider (Okta, Azure AD, PingFederate) and your SaaS application, the first thing they'll ask for is your SP metadata. This is an XML document that describes your service provider: who you are, where to send SAML assertions, what identity formats you accept, and which certificates to use for signing and encryption. Getting this document right — and generating it consistently — is the difference between a smooth enterprise onboarding and a two-week back-and-forth with IT tickets.

A complete SP metadata document

<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor
  xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
  xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
  entityID="https://app.example.com/saml/metadata"
  validUntil="2026-01-01T00:00:00Z">

  <md:SPSSODescriptor
    AuthnRequestsSigned="true"
    WantAssertionsSigned="true"
    protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">

    <!-- Certificate for signature verification (what we sign with) -->
    <md:KeyDescriptor use="signing">
      <ds:KeyInfo>
        <ds:X509Data>
          <ds:X509Certificate>MIICpDCCAYwCCQD...base64encoded...</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>

    <!-- Certificate for assertion encryption (IdP encrypts assertions with this) -->
    <md:KeyDescriptor use="encryption">
      <ds:KeyInfo>
        <ds:X509Data>
          <ds:X509Certificate>MIICpDCCAYwCCQD...base64encoded...</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>

    <!-- Single Logout endpoint -->
    <md:SingleLogoutService
      Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
      Location="https://app.example.com/saml/logout"/>

    <!-- Accepted NameID formats, in preference order -->
    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
    <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
    <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>

    <!-- Where the IdP posts SAML responses -->
    <md:AssertionConsumerService
      Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
      Location="https://app.example.com/saml/acs"
      index="1"
      isDefault="true"/>

  </md:SPSSODescriptor>

</md:EntityDescriptor>

EntityID: the stable identifier

The entityID is the primary identifier for your service provider. It must be globally unique and — critically — it must never change after you've shared it with an enterprise customer. Changing the entityID is equivalent to deregistering your application from every IdP that's configured to trust it. Every SSO connection will break simultaneously.

The entityID doesn't need to be a URL that resolves to the actual metadata document (though it's good practice if it does). It's a URI used as a unique identifier. Common conventions: https://app.example.com/saml/metadata (most common), urn:example.com:app:production, or a UUID URN. Choose one pattern for your product and use it consistently across all tenant configurations.

AssertionConsumerServiceURL (ACS URL)

The ACS URL is where the IdP posts SAML responses after authentication. This endpoint must:

  • Accept HTTP POST requests (SAML responses are base64-encoded in a form field)
  • Validate the SAML response signature before trusting any claims
  • Check the response's InResponseTo attribute against the original request ID (prevents response injection)
  • Check the Conditions element for validity window and audience restriction

For multi-tenant applications, each customer gets a dedicated ACS URL: https://app.example.com/saml/acs/{orgId} or https://{orgSlug}.app.example.com/saml/acs. This lets you route the SAML response to the correct tenant configuration without embedding tenant state in the SAML flow.

NameIDFormat: how users are identified

The NameID in a SAML assertion is the identifier for the authenticated user. The format affects how you match incoming assertions to your user database:

  • emailAddress: The NameID is the user's email. Easy to match, but email addresses can change. If a user changes their corporate email, they may not be able to log in until you update their record.
  • persistent: An opaque identifier that is stable across sessions and scoped to the SP-IdP pair. Doesn't change when the user's email changes. Preferred for user matching.
  • transient: A new identifier for every session. Useless for user matching — only appropriate if you never need to correlate across sessions.

Best practice: request persistent as your first choice, store the persistent NameID alongside the user record, and fall back to email matching if the IdP doesn't support persistent IDs.

Signing and encryption certificates

SAML uses X.509 certificates for two purposes: signing (proving the authenticity of requests and responses) and encryption (protecting the assertion content from network eavesdroppers). Self-signed certificates are standard for SAML — they're not verifying a domain name, just providing asymmetric key material for the signing/encryption operations.

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
import datetime

def generate_saml_certificate(
    common_name: str = "SAML Service Provider",
    validity_years: int = 10
) -> tuple[str, str]:
    """Generate a self-signed certificate for SAML signing/encryption."""
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
    )

    subject = issuer = x509.Name([
        x509.NameAttribute(NameOID.COMMON_NAME, common_name),
    ])

    cert = (
        x509.CertificateBuilder()
        .subject_name(subject)
        .issuer_name(issuer)
        .public_key(private_key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(datetime.datetime.utcnow())
        .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365 * validity_years))
        .sign(private_key, hashes.SHA256())
    )

    private_key_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    ).decode()

    cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()

    return private_key_pem, cert_pem
Use a 10-year certificate validity for SAML signing certificates. Unlike TLS certificates, SAML certificates are not publicly trusted — a long validity period is appropriate and reduces the operational burden of certificate rotation, which requires coordinating updates with every enterprise customer's IdP admin simultaneously.

When you do need to rotate certificates, the standard approach is to publish both the old and new certificates in your metadata simultaneously for a transition period (30-90 days). Enterprise IT teams with change management processes may need weeks to push the new certificate to their IdP. Dual-publishing lets you rotate without breaking anyone's SSO in the interim.