SAML attribute mapping: getting user data from the IdP into your app

A SAML assertion contains the information your application needs to create or update a user session. But parsing it correctly requires understanding the assertion structure, the NameID formats that identify the user, and the attribute statements that carry claims like email address, name, and group membership. The mismatches between what different IdPs send and what your application expects are the primary cause of broken SSO integrations.

The SAML assertion structure

A SAML 2.0 response contains a signed assertion with three main sections: the subject (who the assertion is about), the authentication statement (how and when authentication occurred), and attribute statements (claims about the subject).

<!-- Simplified SAML 2.0 assertion structure -->
<saml:Assertion>
  <saml:Issuer>https://idp.example.com/saml</saml:Issuer>

  <!-- Subject identifies the authenticated user -->
  <saml:Subject>
    <saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">
      f92f6bce-5a73-4e31-b19e-2c4b3e9d1a2f
    </saml:NameID>
  </saml:Subject>

  <!-- AuthnStatement tells you how authentication occurred -->
  <saml:AuthnStatement AuthnInstant="2022-04-11T10:30:00Z">
    <saml:AuthnContext>
      <saml:AuthnContextClassRef>
        urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
      </saml:AuthnContextClassRef>
    </saml:AuthnContext>
  </saml:AuthnStatement>

  <!-- AttributeStatement carries user claims -->
  <saml:AttributeStatement>
    <saml:Attribute Name="email">
      <saml:AttributeValue>alice@example.com</saml:AttributeValue>
    </saml:Attribute>
    <saml:Attribute Name="firstName">
      <saml:AttributeValue>Alice</saml:AttributeValue>
    </saml:Attribute>
    <saml:Attribute Name="groups">
      <saml:AttributeValue>Engineering</saml:AttributeValue>
      <saml:AttributeValue>Platform</saml:AttributeValue>
    </saml:Attribute>
  </saml:AttributeStatement>
</saml:Assertion>

NameID formats

The NameID is the primary identifier for the user in the assertion. Different IdPs use different NameID formats:

  • Persistent (urn:oasis:names:tc:SAML:2.0:nameid-format:persistent): an opaque, stable identifier for the user that persists across sessions. This is the format to prefer. The value is typically a UUID or hash that does not change even if the user's email or name changes.
  • Transient (urn:oasis:names:tc:SAML:2.0:nameid-format:transient): a temporary identifier that is different for each SSO session. You cannot use this as a stable user identifier for account linking — you must use an attribute value instead.
  • EmailAddress (urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress): the user's email address. Widely supported but problematic if users change their email — the identifier changes and your application creates a duplicate account.
  • Unspecified: the IdP can send any value. You need to agree with the IdP administrator on what it will contain.
// Parsing NameID and determining the stable user identifier
function extractStableIdentifier(
  nameId: { value: string; format: string },
  attributes: Record<string, string[]>,
  connection: SsoConnection
): string {
  const format = nameId.format;

  if (format.includes('persistent') || format.includes('unspecified')) {
    // Use NameID as stable ID — combine with entity ID to namespace it
    return `${connection.entity_id}|${nameId.value}`;
  }

  if (format.includes('transient')) {
    // NameID changes each session — look for a stable attribute
    const stableAttr = connection.attribute_mapping.stable_id;
    if (stableAttr && attributes[stableAttr]?.[0]) {
      return `${connection.entity_id}|${attributes[stableAttr][0]}`;
    }
    // Fall back to email — risky but often the only option
    const email = extractEmail(attributes, connection.attribute_mapping);
    return `${connection.entity_id}|email:${email}`;
  }

  if (format.includes('emailAddress')) {
    // Using email as ID — note this in the integration docs
    return `${connection.entity_id}|email:${nameId.value}`;
  }

  // Default: namespace the NameID with the entity ID
  return `${connection.entity_id}|${nameId.value}`;
}
Always namespace your stable identifier with the IdP's entity ID. Two different IdPs can theoretically issue the same NameID value — without namespacing, a user from IdP A could impersonate a user from IdP B if their NameID values happen to match. This is especially relevant in multi-tenant scenarios where you support SSO from many different organizations.

Attribute statements and name formats

SAML attributes have a Name and optionally a NameFormat. The NameFormat indicates how the Name should be interpreted:

  • urn:oasis:names:tc:SAML:2.0:attrname-format:basic: short names like email, firstName.
  • urn:oasis:names:tc:SAML:2.0:attrname-format:uri: full URI names like urn:oid:0.9.2342.19200300.100.1.3 (used in academic federations).
  • urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified: anything goes.

In practice, you need to handle all of them. The same email attribute might arrive as email, mail, emailAddress, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress, or urn:oid:0.9.2342.19200300.100.1.3 depending on the IdP.

// Extracting attributes from parsed SAML assertion
function parseAttributeStatements(
  rawAttributes: SamlAttribute[]
): Record<string, string[]> {
  const result: Record<string, string[]> = {};

  for (const attr of rawAttributes) {
    const name = attr.Name;
    const values = attr.AttributeValue.map(v =>
      typeof v === 'string' ? v : v['_']  // handle XML text nodes
    ).filter(Boolean);

    result[name] = values;

    // Also index by the local part of URI attributes for easier mapping
    if (name.includes('/') || name.includes(':')) {
      const localName = name.split('/').pop()?.split(':').pop();
      if (localName && !result[localName]) {
        result[localName] = values;
      }
    }
  }

  return result;
}

// Map to your user model using the connection's attribute mapping config
function mapAttributesToUser(
  attributes: Record<string, string[]>,
  mapping: AttributeMapping
): SamlUser {
  function getAttr(key: string): string | undefined {
    return attributes[key]?.[0] || attributes[mapping[key]]?.[0];
  }

  return {
    email: getAttr(mapping.email) ?? getAttr('email') ?? getAttr('mail'),
    firstName: getAttr(mapping.first_name) ?? getAttr('firstName') ?? getAttr('givenName'),
    lastName: getAttr(mapping.last_name) ?? getAttr('lastName') ?? getAttr('sn'),
    groups: attributes[mapping.groups] ?? attributes['groups'] ?? []
  };
}

Multi-value attributes and groups

Group membership is typically sent as a multi-value attribute — multiple AttributeValue elements under a single Attribute element, each containing one group name or ID. Parse these as arrays, not as a single string. Some IdPs send groups as a comma-separated string in a single value — you need to handle both forms.

// Handling multi-value group attributes
function extractGroups(
  attributes: Record<string, string[]>,
  groupAttributeName: string
): string[] {
  const raw = attributes[groupAttributeName];
  if (!raw || raw.length === 0) return [];

  // If it's a single value with commas, split it
  if (raw.length === 1 && raw[0].includes(',')) {
    return raw[0].split(',').map(g => g.trim()).filter(Boolean);
  }

  // Multi-value attribute — return as-is
  return raw;
}

// Map groups to your application roles
function mapGroupsToRoles(
  groups: string[],
  groupRoleMapping: Record<string, string>
): string[] {
  const roles = new Set<string>();

  for (const group of groups) {
    const role = groupRoleMapping[group];
    if (role) roles.add(role);
  }

  return Array.from(roles);
}
← Back to blog Try Bastionary free →