A SCIM endpoint is a REST API that can list, create, modify and disable every identity in your directory, and it is usually protected by one long-lived bearer token pasted into an admin console. That combination is why SCIM misconfigurations tend to be bulk-exposure incidents rather than single-account ones. If you are building the server side, this guide walks through the six controls that decide whether a stolen or misused provisioning token is a nuisance or a breach, with a test you can run against your own endpoint for each one.
If you are new to the protocol, start with Implementing SCIM 2.0 for user provisioning and deprovisioning. For the client-side interoperability problems you hit with Microsoft Entra, see lessons learned implementing SCIM with Microsoft Entra and the SCIM validator. This article covers a different angle: assuming the integration works, how do you make sure it only does what it should?
Why SCIM Endpoints Over-Expose Data
SCIM (RFC 7643 for schemas, RFC 7644 for the protocol) was designed for interoperability between a provisioning client, usually an IdP, and a service provider. The spec describes the wire format well and says comparatively little about authorization policy. RFC 7644 section 7 lists security considerations, but the practical decisions are left to implementers: what a token may read, how many results a query may return, which attributes are writable.
Three properties make the endpoint attractive to attackers:
- It is a directory API.
GET /Userswith a filter or pagination is a documented way to enumerate every account, including emails, group membership andactivestatus. - The credential is static. Most connectors (Entra ID’s provisioning secret token, Okta’s HTTP header or Basic auth, many SaaS “SCIM API keys”) are long-lived and rarely rotated.
- It is write-capable. A token that can
POST /UsersandPATCHgroup membership can create a persistent backdoor account without ever touching the login flow.
You can see the read-side problem in a single request. Against a test environment you own:
export BASE=https://idp.example.com/scim/v2
export TOKEN=<provisioning-token>
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/Users?startIndex=1&count=100000" \
| jq '{total: .totalResults, returned: (.Resources | length), perPage: .itemsPerPage}'
A hardened server answers with a capped returned and itemsPerPage (say 100) and expects the client to page. A permissive one returns thousands of records in one response.
Control 1: Scope the Token to the Application, Not the Tenant
The most common design flaw is a single SCIM token that maps to “the provisioning service” rather than to a specific application or tenant. If the server authenticates the token and then queries the whole user table, the token’s real scope is everyone.
Server-side, bind the token to a tenant or application ID at issuance and apply it to every query, not only to writes:
# FastAPI-style handler: every query is constrained by the token's tenant
@router.get("/scim/v2/Users")
def list_users(count: int = 100, startIndex: int = 1,
filter: str | None = None,
ctx: TokenContext = Depends(require_scim_token)):
count = min(max(count, 0), MAX_PAGE) # server-side cap
query = db.users.where(tenant_id=ctx.tenant_id) # never trust the client
if filter:
query = apply_scim_filter(query, filter)
total = query.count()
rows = query.offset(startIndex - 1).limit(count).all()
return scim_list_response(rows, total, startIndex, len(rows))
Beyond scoping, treat the token like any other long-lived secret:
- Store only a hash server-side, show the token once at creation.
- Support rotation with overlapping validity so you can rotate without breaking the connector.
- Prefer OAuth 2.0 client credentials with a short-lived access token when the client supports it. Entra and Okta both support OAuth-based SCIM connections for some integration types, which removes the static secret from the picture.
- Pin allowed source IP ranges where the IdP publishes them.
Control 2: Cap Pagination and Constrain Filters
RFC 7644 section 3.4.2.4 says the server decides how many results to return per page, and that a response may contain fewer resources than the client asked for. That gives you explicit permission to enforce a ceiling. startIndex is 1-based, and a count of 0 is valid and returns only totalResults.
Enforce three limits:
| Limit | Suggested value | Why |
|---|---|---|
Maximum count | 100 to 200 | Stops single-request dumps |
Maximum startIndex depth | Optional, tenant-sized | Deep offset scans are expensive |
| Filter length | 1,024 characters | Bounds parser and DB work |
Then restrict what filters can do. SCIM filter syntax (userName eq "bjensen", emails.value co "example.com", meta.lastModified gt "2026-01-01T00:00:00Z") is a small query language. If you translate it to SQL by string concatenation, you have created an injection point reachable with a bearer token:
# Probe: does the server treat the value as data or as SQL?
curl -s -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode "filter=userName eq \"x' OR '1'='1\"" \
"$BASE/Users" | jq '.totalResults'
If that returns anything other than 0, stop and fix it. The correct approach is to parse the filter into an AST, validate each attribute path against an allow-list from your schema, and bind values as parameters:
ALLOWED = {"userName": "user_name", "emails.value": "email",
"active": "active", "meta.lastModified": "updated_at"}
def apply_scim_filter(query, expr):
node = parse_scim_filter(expr) # raises on unknown grammar
attr = ALLOWED.get(node.attr)
if attr is None:
raise ScimError(400, "invalidFilter", f"unsupported attribute {node.attr}")
return query.where(attr, node.op, node.value) # parameterized
RFC 7644 defines the error types to return: invalidFilter for a bad expression and tooMany when a filter matches more than the server is willing to return. Using them gives well-behaved clients something to act on.
Also watch for case sensitivity. userName is case-insensitive by default in the core schema, so make sure uniqueness and lookups agree, otherwise Admin and admin can become two accounts that collide downstream.
Control 3: Never Return What the Client Should Not Read
RFC 7643 section 7 gives each attribute a returned characteristic: always, never, default or request. The core password attribute is returned: never. Violations usually come from custom extension schemas, where a developer adds apiKey, mfaSecret or recoveryCodes and the serializer happily includes the whole row.
Audit what a single user resource actually returns:
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/Users/<id>" | jq 'paths(scalars) | join(".")'
Review every path in that output. Anything that functions as a credential, or any internal field (database ids, internal role flags, password hashes) is a leak. Fix it at the serializer, with an explicit allow-list of attributes per resource type rather than a deny-list that goes stale when someone adds a column.
Also check the discovery endpoints, which are unauthenticated on some servers:
curl -s $BASE/ServiceProviderConfig | jq
curl -s $BASE/Schemas | jq '.Resources[].id'
curl -s $BASE/ResourceTypes | jq '.Resources[].name'
These are meant to describe capabilities, which is fine. Confirm they do not reveal internal schema names or extension attributes you would rather not publish.
Control 4: Authorize PATCH on Privileged Attributes
Provisioning clients legitimately change profile data and active. They should not automatically be able to change who is an administrator. RFC 7644 PATCH operations (add, remove, replace with a path) can target roles, groups, or a custom enterprise extension, and a server that applies them blindly turns a provisioning token into a privilege-escalation primitive.
Test it with a low-privilege token against a throwaway account:
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/scim+json" \
"$BASE/Users/<id>" -d '{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "add", "path": "roles", "value": [{"value": "admin"}]}]
}'
A hardened server returns 403 (or 400 with a mutability scimType) and logs the attempt. The same applies to group membership for groups that carry elevated rights, and to userName or email changes that could hijack an account at a downstream service that matches users by email.
Implement this as a policy table, not scattered if statements:
PRIVILEGED_PATHS = {"roles", "groups", "urn:example:params:scim:schemas:extension:admin:2.0:User"}
def authorize_patch(ctx, operations):
for op in operations:
root = op.get("path", "").split("[")[0].split(".")[0]
if root in PRIVILEGED_PATHS and "scim:admin-write" not in ctx.scopes:
raise ScimError(403, None, f"path '{root}' requires scim:admin-write")
Remember that a PATCH without a path carries attributes inside value, so inspect those keys too.
Control 5: Turn Bulk Off, or Bound It
The Bulk endpoint (POST /Bulk, RFC 7644 section 3.7) lets a client send many operations in one request. It is optional. Most IdP connectors do not use it, so the safest configuration is often to disable it and say so in discovery:
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 }
If you do enable it, publish and enforce limits, for example maxOperations: 100 and maxPayloadSize: 1048576. Two details matter. First, each operation inside a bulk request must pass the same authorization as the standalone request, because it is easy to check the token once at the top and then skip per-operation policy. Second, bulkId cross-references let one operation refer to another’s result; reject cycles and cap resolution depth so a crafted payload cannot make the server loop.
Control 6: Log Every Mutation and Alert on Anomalies
Even a well-built endpoint will eventually see a leaked token. What you want is detection within minutes. Write a structured audit event for every create, patch, delete and bulk operation:
{
"ts": "2026-10-06T14:02:11Z",
"actor": "scim-token:3f9a1c",
"tenant": "t-1042",
"op": "PATCH",
"resource": "Users/2819",
"paths": ["active"],
"src_ip": "203.0.113.25",
"result": 200
}
Useful alerts that cost almost nothing to run:
- Read volume: more than a few times the normal number of
GET /Userspages per hour for one token. - New source IP: a token that has only ever called from the IdP’s published ranges suddenly appearing elsewhere.
- Privileged path writes: any
rolesorgroupschange by a non-admin token. - Mass deactivation: a burst of
active: falseis both an outage and a possible destructive attack. - Off-schedule creation:
POST /Usersoutside the connector’s normal sync window.
Because the endpoint is meant to be called by software, a baseline is easy to establish. A real IdP connector is very regular, and anything irregular stands out.
A Quick Self-Audit Checklist
Run this against a test tenant before you ship or after you inherit an integration:
| Check | Pass condition |
|---|---|
count=100000 request | Returns at most your page cap |
| Token scope | Cannot read users from another tenant or app |
password and secrets | Absent from every response |
Injection probe in filter | totalResults is 0, no 5xx |
PATCH roles with a profile-only token | 403 and an audit entry |
/Bulk | Disabled, or limits enforced per operation |
| Discovery endpoints | Reveal nothing internal |
| Audit log | Every write recorded with actor and source IP |
| Token rotation | Documented procedure, tested overlap |
What to Do If a Token Leaks
Rotate first, investigate second. Issue a new token, update the IdP connector, revoke the old one, then review the audit log for the window since the token was last known safe. Look specifically for reads that exceed the normal sync pattern, new accounts you did not provision, and privileged-path changes. Disable any account created or elevated outside the normal flow and force credential reset where a password could have been set. This is also the moment to move the integration to short-lived OAuth credentials if the connector allows it.
Conclusion
SCIM endpoints rarely fail through exotic bugs. They fail because a directory API was given a single static credential with tenant-wide scope, no result cap, and write access to roles. Apply the six controls above, token scope, pagination and filter limits, attribute-level returns, PATCH authorization, bounded Bulk and audit logging, and a leaked token becomes a contained, detectable event. For the protocol basics and sample server code, see our SCIM 2.0 implementation guide, and use the JWT decoder to inspect the claims on any OAuth-based SCIM access tokens you issue.




