---
title: "Single sign-on and SCIM"
description: "Sign people in to an Anlyon workspace through your OIDC or SAML identity provider, require it for everyone, and let your directory add, re-role and remove members over SCIM 2.0."
---

A workspace can connect one identity provider, over OpenID Connect (OIDC) or SAML, and one directory over SCIM 2.0. Single sign-on decides how people sign in. SCIM decides who is a member and with which role.

The people who can approve what your agents do in production should be the people your company says they are. With single sign-on, someone who leaves loses access when your identity provider disables them, and with SCIM their membership, their API keys and their apps' grants go too, without anyone remembering to clean up in Anlyon.

## Who can set this up

| What you do | Who can do it |
| --- | --- |
| Read the identity provider and the directory settings | An admin |
| Connect, verify or remove an identity provider, change its default role, create the SCIM token, map groups to roles | An admin |
| Turn the requirement on or off, or remove a provider while it is required | An admin with [emergency access](/account/workspaces#keep-emergency-access) |

Everything is managed in the console under **Settings → Single sign-on** and **Settings → Directory sync**. The routes on this page are console routes: they take a signed-in console session (the `better-auth.session_token` cookie), and an API key cannot call them. They are not in the [API reference](/api-reference). The SCIM endpoints your directory calls take the SCIM token instead.

Single sign-on and SCIM are Team features: they are on the Team and Enterprise plans, and on every plan while paid plans are not on sale. Creating or changing one on another plan answers `403 PLAN_FEATURE_REQUIRED`. A setup made earlier keeps working on any plan, and you can always turn the requirement off or delete the setup. See [which plans have the Team features](/account/billing#which-plans-have-the-team-features).

## Connect an identity provider

Register the provider with the email domains it speaks for: 1 to 20 of them. A domain another workspace has verified cannot be claimed again (`409`). `defaultRole` is `admin`, `member`, `approver` or `viewer`, and `member` when you leave it out.

```bash OIDC
curl -X POST https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/sso \
  -H "Cookie: better-auth.session_token=$SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "protocol": "oidc",
    "domains": ["example.com"],
    "defaultRole": "member",
    "oidc": {
      "issuer": "https://login.example-idp.com",
      "clientId": "YOUR_CLIENT_ID",
      "clientSecret": "YOUR_CLIENT_SECRET"
    }
  }'
```

```bash SAML
curl -X POST https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/sso \
  -H "Cookie: better-auth.session_token=$SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "protocol": "saml",
    "domains": ["example.com"],
    "defaultRole": "member",
    "saml": {
      "entityId": "https://idp.example.com/metadata",
      "ssoUrl": "https://idp.example.com/sso/saml",
      "certificate": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
      "emailAttribute": "email",
      "nameAttribute": "displayName"
    }
  }'
```

- **OIDC.** Anlyon reads the issuer's discovery document (`/.well-known/openid-configuration`), which must be served over HTTPS from a public address. Sign-in uses the authorization code flow with PKCE and the scopes `openid`, `email` and `profile`. The client secret is encrypted at rest and never returned.
- **SAML.** `entityId` is the identity provider's entity ID and must be a URL. `ssoUrl` is its single sign-on URL (HTTP-Redirect binding). `certificate` is its signing certificate, PEM or bare base64. Assertions must be signed. `emailAttribute` and `nameAttribute` name the assertion attributes that carry the email and the display name, `email` and `displayName` unless you say otherwise. Sign-in started by the identity provider is off: people start at Anlyon's sign-in page.

The response, and the console, give what to enter in the identity provider, under `serviceProvider`:

| Protocol | Field | What it is |
| --- | --- | --- |
| OIDC | `oidcRedirectUri` | The redirect URI to allow in the OIDC app: `https://api.anlyon.com/api/auth/sso/callback/<providerId>` |
| SAML | `samlAcsUrl` | The Assertion Consumer Service URL. Okta calls it the Single sign-on URL. Entra ID calls it the Reply URL |
| SAML | `samlEntityId` | Anlyon's entity ID, which is also its metadata URL. Okta calls it the Audience URI (SP Entity ID). Entra ID calls it the Identifier (Entity ID) |
| SAML | `samlMetadataUrl` | Anlyon's SAML metadata, for a provider that imports it |

Copy them from the response rather than building them by hand: `<providerId>` is generated when you register. The response, which `GET /api/v2/workspaces/{workspaceId}/sso` also returns (or `null` when there is none), looks like this for OIDC:

```json
{
  "success": true,
  "data": {
    "workspaceId": "2f8a1f72-7cf9-4fef-95e8-df9de5f2f5d0",
    "providerId": "anlyon-4f9c2a7b1e6d3c80",
    "protocol": "oidc",
    "issuer": "https://login.example-idp.com",
    "domains": ["example.com"],
    "domainsVerified": false,
    "domainsVerifiedAt": null,
    "verificationRecords": [
      { "domain": "example.com", "name": "_anlyon-sso.example.com", "type": "TXT", "value": "anlyon-sso-verification=q8ZtVb3nK1xR7mWp2LcJ9fYd4HsE6aGu" }
    ],
    "enforced": false,
    "defaultRole": "member",
    "serviceProvider": {
      "oidcRedirectUri": "https://api.anlyon.com/api/auth/sso/callback/anlyon-4f9c2a7b1e6d3c80"
    },
    "createdAt": "2026-10-27T10:00:00.000Z",
    "updatedAt": "2026-10-27T10:00:00.000Z"
  }
}
```

A workspace has one identity provider. To replace it, remove it (`DELETE /api/v2/workspaces/{workspaceId}/sso`) and register the new one.

## Verify your domains

Sign-in through the provider is refused until every domain is verified. For each domain, publish the TXT record listed in `verificationRecords`:

```text
_anlyon-sso.example.com.  TXT  "anlyon-sso-verification=<token>"
```

Then check them:

```bash
curl -X POST https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/sso/verify-domains \
  -H "Cookie: better-auth.session_token=$SESSION_TOKEN"
```

Until every record is found, the request answers `422` with `DOMAIN_NOT_VERIFIED`, and the message names the domains still missing. DNS changes can take a while to appear, so try again later. Once they are found, `domainsVerified` is `true`.

## How people sign in

A person chooses single sign-on on the sign-in page and enters their work email. Anlyon sends them to the provider that owns that email's domain.

- **A new person joins with the default role.** The first sign-in through the provider by someone with an email on a verified domain adds them to the workspace with the connection's `defaultRole`: `admin`, `member`, `approver` or `viewer`. It never changes the role of someone already a member. Change the default with `PATCH /api/v2/workspaces/{workspaceId}/sso` and `{ "defaultRole": "viewer" }`. When the workspace has a SCIM directory, the directory alone decides membership, and a sign-in adds nobody.
- **Accounts are created only on verified domains.** The provider can create an Anlyon account only for an email on the workspace's verified domains. Any other email answers `403` with `SSO_DOMAIN_NOT_VERIFIED`. An existing account is linked only when its email is on a verified domain.

## Require single sign-on

An admin with emergency access can require the provider for everyone else in the workspace:

```bash
curl -X PATCH https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/sso \
  -H "Cookie: better-auth.session_token=$SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "enforced": true }'
```

The domains must be verified first, or the request answers `422`. Once it is on:

- A password, social, email-link or two-factor sign-in by a member or admin without emergency access is refused with `SSO_REQUIRED`.
- Every console request to this workspace must come from a session the workspace's provider created. Turning the requirement on ends no session, because a session can also serve other workspaces. The next request from a session that did not come from the provider is refused at this workspace only, and an open live view closes on its next heartbeat.
- Connecting an MCP client or an OAuth app to the workspace needs a session signed in through the provider. Otherwise the consent answers `403` with `SSO_REQUIRED`.
- Deciding an approval [from Slack](/trust-control/slack-approvals) needs a current, unexpired sign-in through the provider.
- API keys and OAuth grants already issued keep working. They are workspace credentials governed by their scopes, not sign-ins.

**Admins with emergency access are exempt.** The one or two admins with emergency access keep password and two-factor sign-in and are never refused by these checks. If the identity provider is down or misconfigured, they sign in and turn the requirement off or replace the provider. Give these accounts strong two-factor authentication.

## SCIM directory sync

SCIM lets your identity provider add people to the workspace, change their role through groups, and remove them. Anlyon implements SCIM 2.0 at:

```text
https://api.anlyon.com/api/v2/scim
```

The console shows the same URL as `baseUrl` under **Settings → Directory sync**.

### Create the SCIM token

Create the workspace's SCIM token in the console, or:

```bash
curl -X POST https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/scim/token \
  -H "Cookie: better-auth.session_token=$SESSION_TOKEN"
```

It answers `201` with the token and the directory settings:

```json
{
  "success": true,
  "data": {
    "token": "anlyon_scim_8Jq2Xw4Lr7Tz1Vb9Nc3Mk6Hf5Gd0Sa2PqR7uY1eW3oI",
    "settings": {
      "connected": true,
      "baseUrl": "https://api.anlyon.com/api/v2/scim",
      "tokenPrefix": "anlyon_scim_8Jq2Xw",
      "defaultRole": "member",
      "createdAt": "2026-10-27T10:00:00.000Z",
      "rotatedAt": null,
      "lastUsedAt": null,
      "roleMappings": []
    }
  }
}
```

`GET /api/v2/workspaces/{workspaceId}/scim` returns the same `settings` without the token. The token starts with `anlyon_scim_` and is shown once: Anlyon keeps only its hash. The directory sends it as `Authorization: Bearer <token>`, and the token also names the workspace. Calling the same route again rotates it, and the old token stops working at once. `DELETE /api/v2/workspaces/{workspaceId}/scim/token` disconnects the directory: the token stops working, and the members it provisioned stay, with the role they had.

The directory adds only people whose email is on a domain verified for this workspace's single sign-on, so [verify your domains](#verify-your-domains) before you connect it. Until a domain is verified, the directory can update and deactivate existing members but cannot add anyone.

### What the directory can call

| Resource | Methods |
| --- | --- |
| `/Users` | `GET` (list, with an `eq` filter on `userName`, `externalId` or `id`), `POST` |
| `/Users/{id}` | `GET`, `PUT`, `PATCH`, `DELETE` |
| `/Groups` | `GET` (list, with an `eq` filter on `displayName`, `externalId` or `id`), `POST` |
| `/Groups/{id}` | `GET`, `PUT`, `PATCH`, `DELETE` |
| `/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | `GET` |

Responses use `application/scim+json` and the SCIM error shape. `PATCH` accepts the `PatchOp` shapes Okta and Entra ID send: `add`, `replace` and `remove`, in any letter case, a path-less `replace` whose value is an object of attributes, booleans sent as the strings `"True"` and `"False"`, and group member removal by a `members[value eq "..."]` path or by a value list. Bulk operations, sorting and ETags are not supported. A filter other than one `eq` comparison answers `400` with `invalidFilter`. Pages hold at most 200 resources.

A User needs an email: the primary entry of `emails`, or a `userName` that is an email address. A SCIM User is a membership of this workspace, not an account. Creating one creates an Anlyon account with no password, or links the existing account for that email. Either way the address must be on a domain verified for this workspace's single sign-on, unless the person is a member already; any other address answers `400` with `invalidValue`, whether or not an Anlyon account exists for it. A `userName`, `externalId` or `displayName` longer than 512 characters answers `400`.

### Map directory groups to roles

Map directory groups to workspace roles in the console, or:

```bash
curl -X PUT https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/scim/role-mappings \
  -H "Cookie: better-auth.session_token=$SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "mappings": [
      { "groupName": "anlyon-admins", "role": "admin", "priority": 10 },
      { "groupName": "anlyon-approvers", "role": "approver", "priority": 5 }
    ]
  }'
```

A person gets the role of the highest-priority mapping among the groups they are in, matched by group name ignoring case. A tie goes to the mapping listed first. Someone in no mapped group gets the directory's default role, `member` unless you change it (`PATCH /api/v2/workspaces/{workspaceId}/scim` with `{ "defaultRole": "viewer" }`). Any role can be mapped. The call replaces every mapping: send up to 100, each with a `groupName` of up to 256 characters and an optional whole-number `priority` from -1000 to 1000 (0 when left out). Changing the mappings or the default re-applies every provisioned person's role.

### What happens when the directory removes someone

When the directory sets a user's `active` to `false`, or deletes the user, that person leaves **this workspace only**:

- their membership is removed;
- the API keys they created in this workspace are revoked (kept, not deleted, so their history stays readable);
- their apps' OAuth grants to this workspace end.

Their account, their other workspaces and their sessions elsewhere are untouched. A console session they hold is refused at this workspace from its next request. Setting `active` back to `true` restores the membership with the role their groups give. Removing a member in the console revokes their keys and grants the same way.

**Admins with emergency access are out of the directory's reach.** SCIM cannot deactivate, delete or re-role them, and group mappings skip them: the request answers `400` with `mutability`. Nor can SCIM remove or demote the workspace's last admin. Remove their emergency access in the console first.

### Set up the directory in Okta or Entra ID

Every provider asks for the same two values:

- **The base URL** above. Okta calls it the SCIM connector base URL. Entra ID calls it the Tenant URL.
- **The token**, sent as a bearer token in the `Authorization` header. Entra ID calls it the Secret Token.

Okta and Entra ID look a user up by `userName` or `externalId`, and a group by `displayName`, before they create one. Anlyon answers those lookups.

## Find these changes in the audit log

Every change on this page is an audit record in the `access` category, written with the change: `sso.connection_created`, `sso.connection_deleted`, `sso.domains_verified`, `sso.settings_updated`, `scim.token_created`, `scim.token_revoked`, `scim.role_mappings_replaced`, `scim.settings_updated`, and for people `member.provisioned`, `member.deactivated`, `member.reactivated`, `member.deprovisioned`, `member.profile_updated` and `member.role_changed`. A key revoked because its creator lost access to the workspace is an `api_key.revoked` record whose `data.reason` is `member_deprovisioned` (the directory), `member_removed` (an admin) or `member_left` (they left). See [Audit records](/trust-control/audit).

## Errors

| Status and code | When | What to do |
| --- | --- | --- |
| `401` | No console session, or an API key sent to these routes. | Sign in to the console as an admin. |
| `403` | Your role is not admin, or the change needs emergency access and you do not hold it. | Ask an admin with emergency access. |
| `403 PLAN_FEATURE_REQUIRED` | Creating or changing single sign-on or SCIM on a plan without the Team features. | See [which plans have them](/account/billing#which-plans-have-the-team-features). |
| `409` | A domain is already verified by another workspace. | Use domains only this workspace owns. |
| `422 DOMAIN_NOT_VERIFIED` | Verifying before the TXT records are visible, or requiring single sign-on before the domains are verified. | Publish the records, wait for DNS, and try again. |
| `403 SSO_DOMAIN_NOT_VERIFIED` | A sign-in through the provider with an email outside the verified domains. | Add and verify that domain, or sign in with a work email. |
| `SSO_REQUIRED` | A password or other sign-in, or an OAuth consent, by someone without emergency access while single sign-on is required. | Sign in through the provider. |
| SCIM `400 invalidFilter`, `invalidValue`, `mutability` | An unsupported filter, an email outside the verified domains, or a change to an admin with emergency access or the last admin. | Fix the request in the directory, or change the member in the console. |

## Related

- [Workspaces and team management](/account/workspaces): the four roles, emergency access and roles per environment.
- [Slack approvals](/trust-control/slack-approvals): deciding from Slack in a workspace that requires single sign-on.
- [Audit records, streaming and export](/trust-control/audit): where these changes are recorded.
- [Authentication & API keys](/authentication): the credentials that keep working under the requirement.
