SCIM 2.0 (System for Cross-domain Identity Management) is the standard protocol for automated user provisioning. Enterprise identity providers like Okta and Azure AD push user and group changes to UniAuth via SCIM, keeping directory data synchronized. When a single UniAuth deployment serves multiple organizations, every SCIM operation must be scoped to the correct tenant. This post explains how we achieved that isolation, the trade-offs we considered, and the security boundaries we enforce.
The Problem: One Token, Many Tenants
In the initial SCIM implementation, authentication used a single bearer token per OAuth client. The OAuth client was registered at the deployment level, not the organization level. This meant a SCIM token could, in theory, read and modify users across all organizations. The scope of potential damage from a compromised SCIM token was the entire deployment.
The fix required changes at three levels: authentication (tying tokens to organizations), data access (scoping queries to the authenticated organization), and error responses (preventing information leakage across tenant boundaries).
The authenticateScimRequest Change
The core change was to the authenticateScimRequest() function in lib/scim.ts. Previously, it returned only the clientId associated with the bearer token. Now it returns a ScimAuth object that includes the orgId:
export interface ScimAuth {
clientId: string
/** When non-null, all SCIM operations are confined to this organization. */
orgId: string | null
}
export async function authenticateScimRequest(
authHeader: string | null
): Promise<ScimAuth | null> {
if (!authHeader?.startsWith("Bearer ")) return null
const token = authHeader.slice(7)
const tokenHash = hashToken(token)
const { rows } = await pool.query(
"SELECT client_id, org_id FROM oauth_clients WHERE scim_token_hash = $1 AND is_active = TRUE",
[tokenHash]
)
if (rows.length === 0) return null
const row = rows[0]
return { clientId: row.client_id, orgId: row.org_id }
}
The org_id comes from the oauth_clients table. When an admin creates an OAuth client and associates it with an organization, the client's SCIM token inherits that organization scope. A "global" client (no org_id) still has deployment-wide access, which is appropriate for the super-admin use case, but tenant-specific clients are now the default for enterprise provisioning.
Scoping Users via organization_members
Users in UniAuth do not belong to a single organization. A user can be a member of multiple organizations (think of an employee who is a member of both "Engineering" and "Security" organizations within the same company). The user row itself is organization-agnostic — the relationship is maintained in the organization_members junction table.
This means SCIM user queries cannot simply filter by users.org_id (that column does not exist). Instead, they must JOIN through organization_members:
// Scoping a SCIM user list to an organization
export async function userInScimScope(
userId: string,
auth: ScimAuth
): Promise<boolean> {
if (!auth.orgId) return true // global token
const { rows } = await pool.query(
"SELECT 1 FROM organization_members WHERE user_id = $1 AND org_id = $2 LIMIT 1",
[userId, auth.orgId]
)
return rows.length > 0
}
Every SCIM endpoint that operates on a user by ID calls userInScimScope() before performing the operation. The SCIM user list endpoint adds the JOIN to its query, so the result set is naturally scoped without a post-query filter.
The Performance Trade-Off
Adding a JOIN to every user query introduces a performance cost. We evaluated two approaches:
- JOIN at query time — correct by construction, no stale data, but O(users * orgs) scan in the worst case.
- Denormalized
org_idon the users table — fast, but only supports single-org membership and requires keeping the denormalized value in sync.
We chose the JOIN approach because multi-organization membership is a real use case in enterprise deployments, and the organization_members table has a composite index on (org_id, user_id) that makes the lookup O(1) for individual user checks and efficient for list pagination.
Scoping Groups via Direct org_id
Unlike users, SCIM groups have a simpler ownership model: a group belongs to exactly one organization. The scim_groups table has a direct org_id column:
export async function groupInScimScope(
groupId: string,
auth: ScimAuth
): Promise<boolean> {
if (!auth.orgId) return true
const { rows } = await pool.query(
"SELECT 1 FROM scim_groups WHERE id = $1 AND org_id = $2 LIMIT 1",
[groupId, auth.orgId]
)
return rows.length > 0
}
This is simpler and faster than the user scoping because there is no JOIN — just a direct column filter. When a SCIM client creates a new group, the org_id is automatically set to the authenticated token's organization. A global token can create groups in any organization by specifying the org ID in the request.
Bulk Endpoint: Per-Operation Scope Checks
The SCIM Bulk endpoint (/api/scim/v2/Bulk) accepts a batch of operations in a single request. Each operation can target a different resource (create a user, update a group, delete a member). The naive approach would be to validate the token's scope once at the request level and then execute all operations. This is insufficient because a single bulk request might contain operations targeting different organizations.
Instead, we validate scope per operation:
// Simplified bulk processing loop
for (const op of operations) {
if (op.method === "POST" && op.path === "/Users") {
// New user creation — will be added to auth.orgId
const result = await createScimUser(op.data, auth)
results.push(result)
} else if (op.method === "PUT" && op.path.startsWith("/Users/")) {
const userId = extractId(op.path)
if (!await userInScimScope(userId, auth)) {
results.push({ status: "404", /* not 403 */ })
continue
}
const result = await updateScimUser(userId, op.data, auth)
results.push(result)
}
// ... similar for Groups, DELETE, PATCH
}
Each operation that references an existing resource checks whether that resource is in scope. If it is not, the operation fails with that resource's individual error response, but the remaining operations in the batch proceed. This follows the SCIM spec's requirement that bulk operations are independent — one failure does not roll back the others.
404-Not-403: Information Leak Prevention
A subtle but important design decision: when a SCIM request targets a resource that exists but is outside the token's organization scope, we return 404 Not Found, not 403 Forbidden.
Why? A 403 response confirms that the resource exists. An attacker who has compromised a scoped SCIM token for Organization A can enumerate resources in Organization B by sending GET requests and checking for 403 vs 404. If the response is always 404 for out-of-scope resources, the attacker cannot distinguish "this user does not exist" from "this user exists but is in another organization."
This is the same information-leak prevention pattern we apply throughout UniAuth: login failure returns "Invalid credentials" rather than "User not found" or "Wrong password"; OAuth token introspection returns {"active": false} for expired, revoked, and nonexistent tokens alike.
Schema Design: Why Not Row-Level Security
PostgreSQL supports row-level security (RLS) policies that could enforce tenant isolation at the database level. We considered this approach and decided against it for three reasons:
- Connection pooling. UniAuth uses a shared connection pool (
pg.Pool). RLS policies require setting a session variable (SET app.current_org = '...') on each connection, which interacts poorly with connection pooling — you must guarantee the variable is reset when the connection is returned to the pool, or a subsequent request on the same connection inherits the wrong org context. - Testability. Our test suite uses a mock database module that returns canned query results. RLS policies are invisible to the mock layer, which means tenant isolation would not be tested by unit tests. By implementing scoping in application code, every scope check is explicitly visible in tests.
- Mixed scoping models. Users are scoped via a JOIN (multi-org membership), while groups are scoped via a direct column. RLS cannot easily express both patterns with a single policy, and maintaining two different RLS policies per table increases complexity without adding safety beyond what the application checks already provide.
The Auth Model: Bearer Token per Client
Each OAuth client can have at most one SCIM bearer token, stored as a SHA-256 hash in the oauth_clients.scim_token_hash column. The plaintext token is shown once at creation time and never stored. This is the same pattern used for personal access tokens and client secrets throughout UniAuth.
When an enterprise customer connects their identity provider (e.g., Okta) to UniAuth for SCIM provisioning, the admin creates an OAuth client associated with the customer's organization, generates the SCIM token, and pastes it into the upstream provider's SCIM configuration. The token is now inherently scoped to that organization — the admin does not need to configure scoping separately.
Token rotation follows the standard pattern: generate a new token, update the upstream provider's configuration, and the old token is immediately invalidated (the hash is overwritten). There is no grace period for dual tokens; the switch is atomic. This simplicity is possible because SCIM tokens are machine-to-machine — there is exactly one consumer (the upstream IdP) and it can be updated atomically.
Lessons Learned
Multi-tenant isolation in SCIM was more nuanced than we initially expected. The key insight was that users and groups have fundamentally different ownership models (multi-org vs single-org), which means a one-size-fits-all scoping mechanism does not work. The per-operation validation in bulk endpoints was an easy requirement to miss during the initial design but critical for security. And the 404-not-403 pattern, while well-known in the security community, was not obvious in the SCIM context until we explicitly modeled the threat of cross-tenant enumeration.
If you are building multi-tenant SCIM support, we recommend starting with the question "what does a compromised token see?" and working backward from there. The answer should be "nothing outside its tenant, and it cannot even tell whether things exist outside its tenant."