SCIM contract
SCIM 2.0 (RFC 7643 and RFC 7644) for Users: create, read, list with a filter, patch the active flag and deactivate. Groups, bulk, sorting, ETags and password changes are not implemented, and the ServiceProviderConfig says so. The base URL of this deployment is https://auth.phoenixcodes.org/api/scim/v2.
Tokens
Every request carries Authorization: Bearer gh_scim_…. A workspace owner or admin creates a token under Single sign-on, Provisioning (the page asks the policy engine for scim:manage, which also needs a second factor). A token belongs to one workspace and can only act inside it.
- 256 random bits behind the
gh_scim_prefix, so a secret scanner can recognise a leaked one. - Shown once. Only its SHA-256 is stored; a token that long needs no slow hash, and the lookup is a plain read of that hash.
- Revoking takes effect on the next request. At most 5 tokens are active per workspace. Creating, revoking and every use that changes something are in the audit log.
Endpoints
Bodies are application/scim+json (plain application/json is accepted). Responses are never cached.
| Request | Does | Success |
|---|---|---|
GET /Users | List people. filter, startIndex (1-based) and count (default 100, at most 200). | 200, a ListResponse |
GET /Users/{id} | One person. The id is the membership id, so it is only valid in this workspace. | 200 |
POST /Users | Create a person. The same userName twice is a conflict, not a duplicate. | 201 with a Location |
PATCH /Users/{id} | Change active, displayName (or name.formatted) and externalId. | 200 with the person |
DELETE /Users/{id} | Deactivate. The row, the history and the audit trail stay, and the person can be reactivated. | 204 |
GET /ServiceProviderConfig, /ResourceTypes, /Schemas | What this service supports. No token needed; limited per address. | 200 |
curl -X POST https://auth.phoenixcodes.org/api/scim/v2/Users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName":"ada@acme.example","displayName":"Ada Lovelace",
"externalId":"00u1abc"}'The User resource
The identity is the email address, so userName is an address, is compared without regard to case and cannot be changed. Gatehouse keeps one display name per person, so name.formatted and displayName are the same string, and emails is returned with the address as the single primary work address (it is accepted and ignored on the way in).
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "mem_k3m9x2q7b4n8d1ya",
"externalId": "00u1abc",
"userName": "ada@acme.example",
"name": { "formatted": "Ada Lovelace" },
"displayName": "Ada Lovelace",
"emails": [{ "value": "ada@acme.example", "type": "work", "primary": true }],
"active": true,
"meta": {
"resourceType": "User",
"created": "2026-10-23T09:14:03.000Z",
"lastModified": "2026-10-23T09:14:03.000Z",
"location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
}
}Filters
The subset a provisioning client sends to find one person or a few: comparisons joined by and and or, not(…), parentheses, and the operators eq ne co sw ew pr (pr means present). Names are case-insensitive, as the RFC says. The filter is parsed into a tree and turned into parameterised SQL; nothing is evaluated as code and nothing is concatenated into a query.
Filterable attributes: id, externalId, userName, displayName, name.formatted, emails.value, active. Value paths such as emails[type eq "work"], comparisons on numbers or dates and unknown attributes are refused with invalidFilter so a client learns it asked for something this service does not do.
userName eq "ada@acme.example"
active eq true and (userName sw "ada" or externalId pr)
not(userName co "contractor")A filter is at most 400 characters, 20 comparisons and 8 levels deep. A test checks every filter against an in-memory evaluator and the SQL it becomes, and requires the two to agree.
PATCH
Both shapes real clients send work, and every operation is checked before any is applied, so a request with one bad operation changes nothing. At most 20 operations. op is add, replace or remove in any case; booleans may arrive as true or as the string "True", which is how Entra sends them.
{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations":[{"op":"replace","path":"active","value":false}]}{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations":[{"op":"replace","value":{"active":false}}]}userName, id and meta cannot be changed (mutability), and active and the names cannot be removed. Setting active to false twice is fine: the call is idempotent and the audit log records the deactivation once.
Rules
- Deactivation is not deletion.
active: falseandDELETEdo the same thing: the membership is deactivated, every session of the person is deleted in the same step, and the audit log records who did it. A person who is deactivated in every workspace they belong to is refused a session by every method (password, passkey, social, SSO). - A token never attaches an account that already exists. If
userNamebelongs to someone who has an account, creating them is a409. Otherwise whoever holds a token for a workspace could provision a stranger's address, then assert it through SSO, and walk into their account. - The last active owner cannot be deactivated, so a directory mistake cannot lock a workspace out of itself.
- The shared demo workspace is protected. Its three demo people cannot be changed or deactivated, it accepts only addresses on the reserved
.exampledomain, and it holds at most 25 provisioned people until its nightly reset. - People provisioned by SCIM show as scim on the Members page, and take the workspace's default role until a role is set; roles from SSO groups apply on their next SSO sign-in.
Errors
Failures are SCIM error bodies with status as a string and a scimType where RFC 7644 defines one. No stack trace or message from below the SCIM layer is ever included.
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "An account with that userName already exists."
}| Status | scimType | When |
|---|---|---|
| 400 | invalidSyntax, invalidValue, invalidFilter, invalidPath, mutability | The body, filter or path is wrong, with a sentence saying which part. |
| 401 | No token, a malformed one, or an unknown or revoked one. WWW-Authenticate: Bearer. | |
| 403 | A protected demo person, the last owner, or a full demo workspace. | |
| 404 | No such person in this workspace. | |
| 409 | uniqueness | The address already belongs to an account. |
| 413, 415 | Body over 16 KB, or not JSON. | |
| 429 | tooMany | Over a limit below. Retry-After says when. |
Limits
- 120 requests a minute per token (
X-RateLimit-LimitandX-RateLimit-Remainingon every response). - 20 failed-token attempts a minute per address; past it the answer is 429, not 401.
- 60 discovery requests a minute per address.
- Request bodies over 16 KB are refused unread.