ENGINEERING

Three SCIM bugs a test suite can’t find

SCIM is the protocol your identity provider uses to create and deactivate accounts in someone else’s software. New hire lands in the right Entra group, and they appear in the tool. Someone leaves, and their access is gone without a ticket. It’s a small, well-specified REST API: a handful of endpoints, a JSON shape defined in RFC 7643, and a filter syntax you can implement in an afternoon.

We built ours, wrote tests, got green, and returned JSON that validated against the spec.

Then we pointed a real Entra ID tenant at it, and found three bugs in about twenty minutes. None of them were reachable from a test suite, because all three lived in the space between “my code returns the right object” and “another company’s code accepts it.”

Bug 1: every single request returned 500

The first one was loud, which is the good kind.

Every SCIM request failed. Not the parsing, not the filtering: the authentication, before any of our endpoint code ran. And the exception came from our own row-level-security tripwire.

Some background. RFF is multi-tenant, and tenant isolation is enforced in Postgres with row-level security rather than trusted application-layer filtering. Every request sets a session variable naming the current tenant, and RLS policies use it. If that variable isn’t set, RLS returns zero rows. Fails closed, which is the direction you want.

Because “returns zero rows” is a silent failure mode, we run a tripwire in development and test: an EF Core interceptor that throws loudly if any code touches an RLS-covered table without first entering a tenant scope. It exists so that an uncovered code path is a screaming failure in CI instead of an empty list in production.

Now the SCIM problem. SCIM authenticates with a bearer token. To find out which tenant a request belongs to, we have to look up that token. The tokens live in ScimTokens, which is a tenant table, which is covered by RLS.

That’s a genuine chicken and egg. We can’t enter a tenant scope until we know the tenant, and we can’t know the tenant until we read a table that requires a tenant scope.

The fix isn’t to weaken the tripwire. It’s to say, explicitly and in a named way, that this specific lookup is a pre-authentication read that legitimately has no tenant yet:

// GOD SCOPE, and it is not optional. 'ScimTokens' is in RlsRuntime.TenantTables, which arms
// the tripwire on this handler - and it runs BEFORE any tenant is known.
await using var guc = await _db.EnterGodModeScopeAsync(
    "ScimAuthHandler: resolve bearer token to a tenant");

var row = await _db.ScimTokens.IgnoreQueryFilters().AsNoTracking()...

The reason to pass a description into that call rather than a bare flag is that it lands in the log. When you audit which paths bypass tenant scoping, you get a list of English sentences explaining why each one is allowed to, instead of a list of stack traces you have to reverse engineer.

We already had this exact pattern for API-key authentication, which has the identical shape and was written months earlier. We just didn’t go and look at it first. That’s the whole lesson: the codebase had already solved this, and re-deriving it cost an outage-shaped hour.

The tripwire wasn’t the bug. The tripwire was the only reason we found the bug in staging rather than in a customer’s Entra sync.

Bug 2: one capital letter, invisible in the browser

The second one was quiet, and it’s my favourite kind of bug because you can stare directly at it and see nothing wrong.

SCIM list responses are wrapped in an envelope:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 1,
  "itemsPerPage": 1,
  "startIndex": 1,
  "Resources": [ { "id": "...", "userName": "..." } ]
}

Look at that last key. Everything in SCIM is camelCase. totalResults, itemsPerPage, startIndex, userName, externalId. And then Resources, capitalised, alone, in the middle of an otherwise perfectly consistent document.

It isn’t a typo in the RFC. It’s genuinely specified that way in RFC 7644, and identity providers look for exactly that key.

We were returning an anonymous C# object, and ASP.NET Core serialises with a camelCase naming policy by default. So Resources went out on the wire as resources. Entra received a syntactically valid JSON document with a totalResults of 1, couldn’t find the Resources array, and concluded there were no users. No error. No warning. A successful sync that did nothing.

You can’t see this bug in a browser, because every JSON viewer renders the key in the font and weight it likes and your eye reads the word, not the capitalisation. We found it by dumping the raw response bytes.

The fix is a named type instead of an anonymous one, so the property can be pinned:

private sealed class ScimListResponse
{
    [JsonPropertyName("schemas")]      public string[] Schemas { get; set; } = ...;
    [JsonPropertyName("totalResults")] public int TotalResults { get; set; }
    [JsonPropertyName("itemsPerPage")] public int ItemsPerPage { get; set; }
    [JsonPropertyName("startIndex")]   public int StartIndex { get; set; }
    [JsonPropertyName("Resources")]    public object[] Resources { get; set; } = [];
}

The general lesson is worth more than the specific fix. A global serialisation convention is a guess about every key you’ll ever emit. It’s a good guess for your own API, where you set the rules. It’s the wrong tool for implementing somebody else’s wire format, where a single key that disagrees with your convention silently becomes a different key. If you’re implementing a spec, name every field explicitly and let the spec win.

Worth noting what did not catch this. Our tests asserted on the deserialised object, so they round-tripped through the same naming policy in both directions and passed happily. A test that serialises with your policy and deserialises with your policy will agree with itself about a name that the rest of the world spells differently. To catch this class of bug you have to assert on the raw string.

Bug 3: staging told the operator to configure production

The third bug didn’t break anything. It was the most dangerous of the three.

Setup instructions matter for SCIM, because there’s a step where a human copies a URL out of your UI and pastes it into their identity provider. We show that URL on the settings page precisely so nobody has to guess it, which removes the most common setup mistake.

The URL is resolved from configuration with a fallback chain, ending in a sensible default:

var baseUrl = (_config["Scim:BaseUrl"] ?? _config["Api:BaseUrl"]
    ?? _config["Install:ApiBaseUrl"] ?? "https://api.getrff.com").TrimEnd('/');

On staging, none of the first three were set. So staging confidently displayed https://api.getrff.com/scim/v2: the production URL, rendered in a copy-me-shaped box, on the environment specifically intended for testing changes before they touch production.

Follow that instruction and you point a real corporate directory at the production tenant while believing you’re testing. Depending on which token you paired it with, that’s either a confusing failure or a real one.

The fix was to add the config key that staging already had under a different name. The interesting part is the category. This isn’t a logic error; the code did exactly what it was written to do. It’s a config-fallback default that’s correct in production and wrong everywhere else, and the fallback made it invisible, because a missing setting produced a plausible answer instead of an error.

Any string your UI hands a user to paste into an external system deserves this scrutiny. A default that’s right in prod and silently wrong in staging is worse than no default, because no default would have failed visibly on the first page load.

What actually found these

Not the test suite. The test suite was green through all three.

Bug 1 needed a request to arrive over the wire with a real bearer token. Bug 2 needed another company’s client to parse our bytes with their expectations rather than ours. Bug 3 needed a human to read the screen and ask what that URL was pointing at.

We have a rule internally that a feature isn’t shipped until somebody has driven the real interface with real data, rather than confirming the API returns 200. SCIM is the strongest argument for that rule we have hit so far, because the API was returning 200. It was returning 200 with an empty result and a wrong key, to a system that reported the sync as successful.

The version that shipped is the one a real Entra tenant provisioned a real user into: created on demand, badged as directory-managed in our UI, and correctly linked rather than duplicated when that user then signed in through SSO.

If you’re building a SCIM endpoint: check your capital R, name every field explicitly rather than trusting a naming policy, and get a real identity provider pointed at it before you call it done. A free Entra developer tenant costs nothing and will find things your tests structurally can’t.


SCIM provisioning is live in RFF for Entra ID and Okta, alongside custom roles. How to set it up, or see it on a real fleet.