In this article
October 13, 2026
October 13, 2026

How to secure a SCIM endpoint: Lessons from CVE-2026-109758

Your SCIM endpoint is an admin API. A default bearer token and a PATCH path parser left Perforce's open to anyone who knew the token and, in one test, to running code. Here is what to check in your own.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

On October 13, 2026, CVE-2026-109758 was published for Perforce Helix Authentication Service (HAS), the component that adds SSO and SCIM provisioning to Perforce's version control server. It is rated 9.3 (critical) under CVSS 4.0.

The bug is not exotic. According to the CVE record, HAS versions 2022.1 through 2025.2 shipped installation templates with a default SCIM bearer token, and the installer copied that value into the live configuration without replacing it. Anyone who knew the default and could reach the endpoint could create, update and deactivate users and groups.

The record then describes a chain. HAS used scim-patch, a popular npm library for applying SCIM PATCH operations, and affected releases of that library have a prototype pollution bug (CVE-2026-61834). A crafted PATCH request could corrupt application state and take down health checks, SCIM and SSO. In a later test, it was confirmed to enable remote code execution on a Node.js 24 and Express deployment.

Perforce removed the underlying defect in HAS 2026.1, released July 17, 2026. The record lists the CVE as not on CISA's Known Exploited Vulnerabilities catalog, and we found no reports of exploitation.

Key facts
CVE CVE-2026-109758, Perforce Helix Authentication Service (HAS)
Severity 9.3, critical (CVSS 4.0)
Published October 13, 2026
Affected HAS 2022.1 through 2025.2
Fixed HAS 2026.1, released July 17, 2026
Root cause Default SCIM bearer token copied into the live configuration
Chained with scim-patch prototype pollution, CVE-2026-61834, fixed in 0.9.2
Exploitation Not on CISA's Known Exploited Vulnerabilities catalog; no public reports found as of October 13, 2026

Few of our readers run HAS. Many of them run a SCIM endpoint they built themselves, and both halves of this bug are easy to reproduce in your own code. This post walks through each one and ends with a checklist.

Diagram titled “How CVE-2026-109758 chains together.” The left column lists five attack steps connected by downward arrows. Step 1: a default SCIM bearer token ships with the install, and the installer copies it into the live config unchanged. Step 2: the attacker calls the SCIM API as the identity provider and can create, update and deactivate users and groups. Step 3: a crafted PATCH path such as toString.x gets past a blocklist of proto, constructor and prototype. Step 4: prototype pollution in scim-patch (CVE-2026-61834) lets writes reach shared built-in objects. Step 5: corrupted state and outages take down SCIM and SSO, and remote code execution was confirmed in a Node.js 24 and Express test. The right column pairs each step with a control. Step 1: generate a unique token per directory at setup and reject every request until one exists. Step 2: scope each credential to one directory, so a leaked token reaches one tenant, not all of them. Step 3: allow only paths your schema defines and return 400 with invalidPath for anything else. Step 4: follow own properties only and upgrade to scim-patch 0.9.2 or later. Step 5: isolate the SCIM endpoint from SSO handlers and template engines. Sources: the CVE-2026-109758 record, published October 13, 2026, and GitHub advisory GHSA-2mhw-wcx5-v3xj.

Why does SCIM endpoint security matter?

SCIM endpoints tend to get treated as plumbing: a URL you hand to a customer's IT team once, which Okta or Entra ID then calls in the background. But look at what the endpoint can do. It creates accounts, deactivates them, and changes group memberships, which most B2B apps map straight to roles. A caller who can write to it can give themselves an account in any tenant the token covers, add that account to an admin group, or deactivate the people who would notice.

That is the same power as your internal admin panel, exposed on the public internet to a machine caller, usually guarded by one string.

The spec does not help much here. RFC 7644, the SCIM protocol, says plainly that it "does not itself define a SCIM-specific scheme for authentication and authorization." So every implementation picks its own, and most picked a long-lived bearer token. We covered why that default ages badly in Why SCIM needs OAuth 2.0 Client Credentials, not just bearer tokens.

How should a SCIM endpoint authenticate requests?

The first failure in the Perforce chain was a credential that shipped with the product. A default credential is the extreme case of a shared credential. Every install has the same one, so knowing it once means knowing it everywhere. The fix is not "remember to change it." It is to make the endpoint impossible to use until a unique credential exists.

What that looks like in practice:

  • Generate tokens at setup, never in templates. No token value should exist in your code, config templates, Helm charts or docs. If the SCIM endpoint has no token configured, it should reject every request.
  • One token per directory. Scope each token to a single customer directory, so a leaked token can only touch that tenant. A global SCIM token is a cross-tenant admin key.
  • Show it once, store a hash. Treat the token like a password: display it at creation, store only a hash, and compare in constant time.
  • Allow two active tokens during rotation. Rotation that requires downtime does not happen. Let the customer add a new token, update their IdP, then revoke the old one.
  • Give tokens a recognizable prefix. It makes leaked tokens easy for secret scanners to find, and it makes the next point unambiguous.
  • Accept the token with or without Bearer. Okta's header token integrations send Authorization: {token} with no prefix. We documented this and other IdP quirks in What Okta and Entra ID actually send to your SCIM endpoint.
  • Offer OAuth 2.0 client credentials where the IdP supports it, so the token on the wire is short-lived.

A minimal version of the token check in Node.js:

  
import { createHash, timingSafeEqual } from "node:crypto";

const sha256 = (s) => createHash("sha256").update(s).digest();

// tokensByDirectory: Map<directoryId, Buffer[]> of SHA-256 hashes.
// Up to two entries per directory while a rotation is in progress.
export function authenticateScim(req, directoryId, tokensByDirectory) {
  const header = req.headers.authorization ?? "";
  // Okta header-token integrations send the token with no "Bearer" prefix.
  const token = header.replace(/^Bearer\s+/i, "").trim();
  if (!token) return false;

  const presented = sha256(token);
  const stored = tokensByDirectory.get(directoryId) ?? [];
  return stored.some((hash) => timingSafeEqual(hash, presented));
}
  

Note what is missing: there is no fallback. A directory with no tokens rejects everything.

How do you validate SCIM PATCH paths safely?

The second failure is treating PATCH paths as trusted input. It is less obvious, and it applies to almost any SCIM server written in JavaScript.

A SCIM PATCH request carries a list of operations. Each operation has an op, an optional path such as name.givenName, and a value. Per RFC 7644, path is optional for add and replace; when it is omitted, value is an object whose keys are attribute names. Either way, the IdP decides which attribute names your server will walk, and in practice those names arrive as dotted strings. Entra ID, for example, sends dotted sub-attribute names and full extension URNs as keys inside a path-less replace when its SCIM compliance flag is turned on.

The natural way to apply a dotted path is to split it and walk the object. Here is a version that looks careful, because it blocks the three names everyone knows to block:

  
function setPath(obj, path, value) {
  const keys = path.split(".");
  let node = obj;
  for (const key of keys.slice(0, -1)) {
    if (["__proto__", "constructor", "prototype"].includes(key)) {
      throw new Error("blocked");
    }
    if (!(key in node)) node[key] = {};
    node = node[key];
  }
  node[keys.at(-1)] = value;
}

const user = { userName: "ada@example.com" };
setPath(user, "toString.polluted", "yes");

console.log({}.toString.polluted); // "yes", on every object in the process
  

The in operator checks inherited properties too. toString is not on user, but it is on Object.prototype, so the loop happily walks into the shared Object.prototype.toString function and writes to it. Every object in the process now sees the attacker's property.

This is the pattern in the scim-patch advisory. The library blocked __proto__, constructor and prototype, but its navigate() and assign() functions still followed inherited properties, so paths like toString.x got through. The same advisory notes that a path-less operation with a dotted key in value is affected as well. On its own the library bug is rated moderate (CVSS 3.1 4.3), because the write is narrower than full Object.prototype pollution. Behind a default token, and next to gadgets like template engines, it became part of a critical chain. It is fixed in scim-patch 0.9.2.

The fix has two parts. Only follow properties the object owns, and only accept paths your schema defines:

  
// Built from your SCIM schema, including extension URNs you support.
const ALLOWED_PATHS = new Set([
  "userName", "active", "displayName",
  "name.givenName", "name.familyName",
  // ...
]);

function setPath(obj, path, value) {
  if (!ALLOWED_PATHS.has(path)) {
    throw new ScimError(400, "invalidPath", `Unknown attribute: ${path}`);
  }
  const keys = path.split(".");
  let node = obj;
  for (const key of keys.slice(0, -1)) {
    if (!Object.hasOwn(node, key)) node[key] = Object.create(null);
    node = node[key];
  }
  node[keys.at(-1)] = value;
}
  

The allowlist does most of the work. A SCIM server already knows its schema, so there is no reason to apply a path it does not recognize. The RFC gives you the right error for it: 400 with scimType of invalidPath. Applying unknown attributes "just in case" is how a provisioning API becomes a general-purpose object writer.

If you use a library to apply PATCH operations, check its version today, and check whether it validates paths against a schema or walks whatever it is given.

What limits the damage if a SCIM token leaks?

The CVE record lists three conditions for the first half of the attack: the default token is still in place, the SCIM endpoint is reachable, and provisioning is configured against a backend. Each one is a place to cut the chain.

  • Limit what SCIM can grant. Map IdP groups to roles explicitly, per organization, and decide whether a SCIM group should ever be able to grant your app's highest admin role without a human approving the mapping.
  • Log every SCIM write with the directory, the token or client that made it, and the before and after values. After an incident like this one, the question is "which users and groups changed that no IdP sync explains," and you can only answer it if you recorded the changes.
  • Restrict network reachability where you can. If the SCIM endpoint shares a process with your SSO handler and your template engine, a bug in one is a bug in all of them. Isolating it limits what a parser bug can reach.

SCIM endpoint security checklist

SCIM endpoint security checklist
Check Why it matters
Authentication
No token values in code, templates or docs A default credential is the same on every install, so knowing it once means knowing it everywhere.
Endpoint rejects all requests until a token exists There is no unsafe fallback state between install and setup.
One token or OAuth client per directory A leaked credential can only reach one customer's tenant.
Tokens hashed at rest, compared in constant time A database leak does not leak working credentials.
Two active tokens during rotation Rotation that needs no downtime is rotation that actually happens.
Accept tokens with and without Bearer Okta header-token integrations send the token with no prefix.
PATCH handling
Every PATCH path checked against your schema Unknown paths get a 400 with invalidPath instead of being applied.
Only own properties followed when applying a path Blocks toString.x style writes to shared built-in objects.
PATCH library on a fixed version (scim-patch 0.9.2 or later) Earlier versions are affected by CVE-2026-61834.
Detection
Every SCIM write logged with before and after values You can find user and group changes that no IdP sync explains.

Can you avoid running a SCIM endpoint yourself?

With WorkOS Directory Sync, WorkOS hosts the SCIM endpoint for each customer's directory. Each directory gets its own credentials: a bearer token generated in the Dashboard, or OAuth 2.0 client credentials with a per-directory token URL, which Okta and Entra ID both support. Your customer's IT contact can generate, rotate and revoke bearer tokens from the Admin Portal, and a directory can hold two active client secrets so rotation does not interrupt the sync. Your app receives the same dsync.* events whichever IdP is on the other side.

You still own what happens after the event arrives, including how groups map to roles. But parsing PATCH operations from every IdP, and keeping the credentials that guard them, stops being your code.

Further reading