Blog
What Entra and Okta really send to a SCIM server
A SCIM server that follows the standard's strict profile refuses the requests Entra and Okta really send. Which requests, and why one setting is enough.
By FoxAuth ·
A company keeps its staff in a directory, such as Okta or Microsoft Entra ID. The directory creates, updates and switches off each person’s account in every application the company uses. This is called provisioning, and SCIM is the protocol the directory uses for it.
Here is how Okta switches off (deactivates) a user over SCIM. The request is in exactly the shape
Okta’s own protocol reference documents. It goes to one of our buckets, acme in the path. A bucket
is this server’s name for a group of end users with their own sign-in rules. Once a bucket has an
address of its own, it issues tokens under its own name, which makes it a separate issuer.
PATCH /acme/scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1Host: auth.example.comAuthorization: scimst_q3TqC1lG0iJ...Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{ "op": "replace", "value": { "active": false } }]}The request has no path, the field that says which attribute to change. Instead, Okta puts the
change itself in value: set active to false. RFC 7644, the SCIM standard, allows that.
But there is also the SCIM 2.0 interoperability profile. A profile is a document that narrows a
standard so that different implementations understand each other. This one says a server must refuse
a PATCH operation (a change to part of a record) that has no path. The profile we build to, the
OpenID IPSIE SCIM profile (Interoperability Profiling for Secure Identity in the Enterprise), is built
on top of that one. The stricter rule has a good reason. Operations without a path are where all the
unclear cases in SCIM’s rules for changes live. So a server that does what the profile asks answers
this request with 400. And the person who left the company yesterday stays active.
I had read both profiles carefully before I wrote a line of the server. I still assumed the interesting work would be following them. It was not. Most customers use one of two directories, Entra or Okta, and neither of them can provision a server that follows those profiles to the letter. So the useful thing to know is exactly where each of them departs from the profiles.
Okta deactivates with a request the profile forbids
I call the ways a client departs from the profiles its dialect. Okta’s dialect is small, so it is
easy to underestimate. Two forms matter. The first is the replace
without a path shown above. That is how Okta deactivates a user and turns them back on, and that is
the most important thing a provisioning client does. The second is a password attribute when Okta
creates a user.
IPSIE forbids password in the user schema outright. The reason is federation: applications keep no
passwords of their own and send the user to the directory to sign in. A directory that pushes
passwords into every application is exactly what federation was meant to get rid of. And our users
sign in through the directory anyway.
How Okta proves who it is also departs from the profiles, and this part surprises people. Okta cannot
get a token for SCIM through OAuth client credentials at all. Client credentials is the OAuth way for
a program to get an access token on its own, with no user involved. Instead, Okta sends a static token:
a fixed string that stays the same from request to request. In its header mode, Okta sends that token
as the whole Authorization value, without the word Bearer in front.
That is why our static tokens start with scimst_. The prefix does two jobs. The server can tell such
a token from an access token without looking anything up, and accept it without Bearer. And a
secret scanner can recognise one that has leaked into a repository. The server stores only the
token’s SHA-256 digest, a fingerprint from which the token cannot be recovered, and shows the token
once.
Entra departs from the profiles even more, and Microsoft admits it with a flag
Microsoft Entra ID departs from the profiles in more places, and stranger ones:
- operation names with a capital letter,
"op": "Replace", where RFC 7644 uses lower case; - true and false sent as strings,
"value": "False"; - a
replacewithout a path that sets several attributes at once. Its keys are sub-attribute names written with a dot and the full names (URNs) of attributes from schema extensions:name.givenNamenext tourn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department; addressesin its default attribute mappings, the settings that decide which user fields Entra sends. Our user schema does not declare that attribute;- a
replacewhose path contains a filter that matches nothing. For example,emails[type eq "work"].value(the work address in the user’s list of emails) for a user who has no work email yet. RFC 7644 says the server should answernoTarget. Entra means “add it”; - and the enterprise
managerattribute. Entra sends it as a bare id,"value": "mgr-1", where the schema says{ "value": "mgr-1" }. And to clear it, Entra sends areplacewith an empty string instead of aremove.
Most of that list came from Microsoft’s documentation and from what other people who built SCIM
servers have reported. The two manager forms did not, and they are the ones I like least. We found
them only when our server failed the Add, Replace and Remove Manager tests in Microsoft’s own SCIM
validator, Microsoft’s test suite for SCIM servers. Nobody had written that behaviour down. The
vendor’s own conformance test is what revealed it.
Microsoft documents a feature flag, aadOptscim062020. An Entra administrator appends it to the
tenant URL, the address of the application’s SCIM endpoint that they enter in Entra. With the flag,
Entra switches some of its older behaviour to the form the standard asks for. True and false sent as
strings are among them. I know of no clearer admission that Entra’s dialect is real. But a server
cannot count on every customer having added the flag.
Here is the whole picture in one place. A strict server follows the profiles to the letter. A tolerant server accepts these forms and does what the client meant. Read each row as a sentence: this client sends this, a strict server refuses it, and a tolerant server does this instead.
Nothing in the left column is rare. It is the everyday traffic of the two clients that cover most of the enterprise market. A server that answers like the Strict column conforms to the profiles, and nobody can use it.
We threw out two libraries: they refused normal requests and let dangerous ones through
For the hard parts, our plan named two well-used libraries. One parses SCIM filters: the search conditions a directory sends to find users. The other applies a PATCH to a stored record. We threw both out. Why we threw them out is more useful than what replaced them.
The filter parser matched quoted strings with a pattern that backtracks: on a run of newlines with no closing quote, the time it takes grows exponentially with the length of the run. The filter comes from the directory, in the query string. So anyone holding a SCIM token could send one such filter and tie up the server. That is a denial of service.
The patch library had more problems. It refused REPLACE. It threw a TypeError on a remove with
no path. It stored "False" as the string "False", not as false. It compared attribute names with
case mattering, where SCIM says case does not matter. And it had two security advisories this year for
prototype pollution. That is when a key such as __proto__ in the input changes properties that every
object in the program shares. So the library was strict about exactly what the real clients do. And it
was lax about the one thing that is really dangerous: keys like __proto__ in a request body that an
outside system writes.
The real clients use only a small part of SCIM’s filter and patch language, so our replacements are
small too. The new filter parser reads the string once, start to finish, and accepts only the filters
IPSIE lists. A property test (a test that runs the code on many random inputs) checks that parsing time
grows only in step with the length of the input. The new patch code checks every path against the
table of attributes we declare. It refuses __proto__, constructor and prototype anywhere in a
body. And every tolerance, every form it accepts only because a real client sends it, sits behind the
same condition, leniency.strict:
const op = opText.toLowerCase();if (op !== 'add' && op !== 'replace' && op !== 'remove') { throw new ScimError( 400, 'invalidSyntax', `${opText} is not a PATCH operation` );}if (leniency.strict && opText !== op) { throw new ScimError(400, 'invalidSyntax', 'the op must be lower case');}
if (path === undefined || path === '') { if (op === 'remove') throw new ScimError(400, 'noTarget', 'remove needs a path'); /* Okta's {"op":"replace","value":{"active":false}}; Entra's multi-attribute replace. */ if (leniency.strict) { throw new ScimError(400, 'invalidSyntax', 'an operation needs a path'); } if (!isObject(value)) { throw new ScimError( 400, 'invalidValue', 'a path-less operation needs an object value' ); } for (const [key, v] of Object.entries(value)) { applyOne(working, op, key, v, leniency, resource); } continue;}Two things in that code are deliberate. First, accepting a form is the normal path and refusing it is
the exception, because the normal traffic is Entra’s and Okta’s dialect. Second, a remove with no
path is refused in every mode. A tolerant reading of it would have to answer “remove what?”. A server
that guesses is how a directory ends up deleting a user’s whole profile.
One setting: a server either follows the profiles fully or accepts Entra and Okta
Each of those forms could have had its own setting, and for a while the design had several. It ended
up with one, scim.strict, off by default. I think one is right, and the reason is about the operator
(the person who runs and configures the server), not about the code. An operator is answering one
question. Am I certifying this server against the profiles, or am I connecting Entra and Okta to it?
A server that keeps only some of the tolerances does not conform to the profiles. So a panel of
separate switches offers dozens of combinations, and all but two of them (everything on, everything
off) are wrong for both purposes. With scim.strict on, every form in the table gets a 400. Only
then does /ServiceProviderConfig, the endpoint where a SCIM server describes what it supports,
declare interopProfileConformant. A server that accepts even one of those forms has no right to
claim it.
Credentials follow the same rule: one switch for each decision an operator actually makes. Here there
are two. Entra gets its token from the bucket’s token endpoint with a client secret. It sends the
secret either with Basic authentication (in the Authorization header) or in the request body,
whichever its administrator picks. Until workload identity federation (a way for a program to sign in
without a stored secret) reaches provisioning, a secret is all Entra can send. Okta sends its static
token.
Neither is what IPSIE asks for. The profile wants a client assertion: a JWT that the client signs to
prove who it is. Entra’s secret and Okta’s static token each have their own setting,
scim.secretCredentials and scim.staticTokens, and both are on by default. An operator who wants
only the profile’s way can turn both off and see exactly what stops working.
The route through the token endpoint has one property I would not give up. A directory is linked to
our server by a provisioning connection: the record that ties one directory to one bucket. From that
connection the server builds the OAuth client scim-<connection id> each time the client is needed.
The client itself is stored nowhere. So SCIM gets the whole token endpoint for free. That includes
its protection against the same client assertion being used twice, and its constant-time comparison of
secrets (it takes the same time however close a guess is, so timing gives nothing away). Changing a
credential is one write to the connection, and there is no hidden copy to keep in step.
The token this endpoint issues is opaque: a random string with no content, which only our server can
look up. It carries the scim scope and nothing else. Its audience is one bucket’s SCIM URL, so it
is no good anywhere else. And it stops working the moment the credential is reissued.
IPSIE §4.1 requires credentials in the request body, then forbids them there
It would be convenient if the profile’s requirements at least agreed with each other. Then
conforming would merely be unpopular. They do not agree. Section 4.1 of the IPSIE SCIM profile
requires the client-credentials grant “with JWT Client Authentication as defined in RFC7523 section
2.2”. That means the client proves who it is with a client assertion. A few lines later, the same
section requires “HTTP Basic authentication in the Authorization request header”. It adds that
sending client credentials such as client_assertion or client_secret in the request body is
prohibited.
But RFC 7523 §2.2 sends the assertion in the request body. That is what the section defines. Basic sends a shared secret, in the header. So no client can meet both requirements, and no server can tell a client which one to break. We follow the JWT requirement, for two reasons: §10 repeats it, and §4.1 carries an editor’s note saying the section should be expanded. We say so in our conformance notes instead of quietly picking one. The profile is draft 00, the first draft. It will be fixed. Until it is, anyone telling you their server fully conforms to §4.1 has not read it twice.
What Microsoft’s and Okta’s tests found, and what we have not tested yet
Microsoft’s SCIM validator is Microsoft’s set of tests for a SCIM server. We run it against our conformance deployment, the copy of our server we keep for such tests. The server passes 23 of the 24 required tests and all seven preview tests, groups included. The one failure is “Patch User - Replace Attributes”. The validator reports that the user’s primary email and phone number are missing. Yet the PATCH response that the validator itself displays contains both. And reading the user afterwards returns both too. Other implementers report the same failure on correct responses, and Microsoft has not answered them. I count it as the validator’s failure. Our conformance notes give the reasoning, so anyone can disagree with it.
Okta’s test suite is the SCIM 2.0 Spec Test. There the deployment passes all eleven required tests and
the optional groups test: 53 of 53 checks. The first run of that test found a real bug of ours. A
request to an address under the SCIM base that the server does not serve got a 404. But the body
said server_error, the code for a fault inside the server.
What we have not run matters as much. Okta’s CRUD test (create, read, update and delete users) needs
an Okta organisation with our integration installed, and we have not run it. There is no /Bulk
endpoint (many operations in one request). Neither Entra nor Okta calls one. It is planned for later,
together with asynchronous bulk operations. But the profile lists it, so without it the profile is
not fully met. Okta’s Universal Logout calls our global token revocation endpoint, which revokes a
user’s tokens. That endpoint has
never been tested against a real Okta organisation either. And we have not applied for certification
of any of this.
Leave strict off for Entra and Okta; turn it on to certify or to test your own client
If you are connecting Entra ID or Okta, leave scim.strict off. Read that as a fact about the
clients, not as loosening your server. Every form the server then accepts has limits. An attribute is
checked against the table of attributes we declare. An attribute we do not declare is ignored, not
stored. And the dangerous inputs, keys like __proto__ and a remove with no path, are refused in
both modes.
Turn it on for a certification run, or when the only client is one you wrote to the profile. Then
treat the result as a test of that client. If your own client fails in strict mode, that is a bug you
can fix. Nobody can say that about Entra’s manager. And if you are writing a SCIM server from
scratch, start from the table above, not from the RFC. The RFC tells you what is allowed. The table
tells you what will arrive at three in the morning, when a customer’s directory runs its first full
sync.