Concepts
Directory attribute normalization
SCIM defines a core schema, and almost nobody sends only the core schema. Every provider adds an extension, every HR system has its own field names, and every customer has configured at least one attribute that exists nowhere else. Normalisation is the layer that turns that into a record with the same shape every time.
How far the names spread
The SCIM core schema covers a useful minimum: a user name, a set of emails, an active flag, a display name. Beyond that, providers use an enterprise extension whose fields are broadly agreed but inconsistently populated, and then each one adds its own namespace on top.
HR systems widen the gap further, because they were designed to describe employment rather than access. A field like manager can arrive as an identifier, an email address, a display name or a nested object, depending on the source — and all four are reasonable ways to express the same fact.
The result is that writing your own integration against one customer produces code that fails on the second. This is the same problem SAML attribute mapping has on the authentication side, arriving through a different door.
| Normalised field | Arrives as | Notes |
|---|---|---|
| emails | emails array, userName, or a primary email extension field | The primary flag is not always set; the first entry is not always primary. |
| first_name | name.givenName, or a flat givenName | Some HR sources send only a full name and expect you to split it. |
| last_name | name.familyName, sn, or surname | The LDAP heritage again. |
| job_title | title, or an enterprise extension jobTitle | Populated by HR systems far more reliably than by identity providers. |
| state | active boolean, or a status string | A status of Terminated and active: false mean the same thing. |
| custom_attributes | Everything else, under a vendor namespace | Where department, cost centre and employee number usually live. |
What a normalised record looks like
Standard fields are lifted into named properties; everything else is preserved under custom_attributes with its values coerced to strings, so a handler never has to branch on whether a number arrived as 4812 or as the string 4812. Nothing is discarded — an attribute the platform does not recognise is still there.
1{2 "object": "directory_user",3 "id": "directory_user_01HQ8ZK3M4N5P6R7S8T9V0W1X2",4 "directory_id": "directory_01HQ8ZK3M4N5P6R7S8T9V0W1X2",5 "idp_id": "00u1a0ufowBJlzPlk357",6 "emails": [{ "primary": true, "value": "avery@foo-corp.example" }],7 "first_name": "Avery",8 "last_name": "Lindqvist",9 "job_title": "Principal Engineer",10 "state": "active",11 "groups": [{ "id": "directory_group_01HQ8ZK3M4N5P6R7S8T9V0W1X2", "name": "Engineering" }],12 "custom_attributes": {13 "department": "Platform",14 "employee_number": "4812",15 "cost_center": "CC-2200",16 "manager_email": "morgan@foo-corp.example"17 }18}
Mapping an attribute your product needs
Anything specific to your application — a licence tier, a site code, a regulatory flag — needs an explicit mapping per directory, because the source field name is different for every customer and no amount of normalisation can guess it.
- 1Connect a test directory and read one user with the API. Look at custom_attributes rather than the customer's documentation; what is sent and what is documented are frequently different.
- 2Note the exact key, including case and any vendor namespace prefix.
- 3Add the mapping on the directory in the dashboard, under identity provider attributes, so the value arrives as a named field.
- 4Write the consuming code to treat the field as optional, with a defined behaviour when it is absent. It will be absent for the next customer.
- 5Decide what happens when the value is present but unrecognised — an unexpected licence tier should log and fall back, not throw inside a webhook handler.
Attributes are informative, not authoritative. A department field says what the customer's HR system believes; it does not by itself decide what somebody may do in your application. Map attributes to your own model and check that, so a change of wording at the source cannot alter access.
Reading attributes defensively
The failure that costs a weekend is not a missing attribute, it is a handler that assumed one. A customer restructures their directory, an optional field disappears for four thousand users at once, and every delivery starts failing — which pauses the endpoint, which stops the events you do care about.
1function toLocalUser(directoryUser) {2 const attrs = directoryUser.custom_attributes ?? {};3 const primary = directoryUser.emails?.find((e) => e.primary) ?? directoryUser.emails?.[0];45 return {6 idpId: directoryUser.idp_id,7 email: primary?.value ?? null,8 name: [directoryUser.first_name, directoryUser.last_name].filter(Boolean).join(" ") || null,9 // Eslesmemis alanlar her zaman opsiyoneldir.10 department: attrs.department ?? null,11 costCenter: attrs.cost_center ?? null,12 active: directoryUser.state === "active",13 };14}