# Single sign-on and SCIM

Sign members in through your OpenID Connect identity provider, require it, and provision or remove members with SCIM 2.0.

Admins connect an identity provider under **Settings, Identity**. Single sign-on decides how members sign in. SCIM keeps the member list in step with the identity provider.

## Single sign-on

Unhandled signs people in with OpenID Connect, using the authorization code flow with PKCE. It works with providers that publish OpenID discovery metadata, such as Okta, Microsoft Entra ID, Google Workspace and Keycloak. SAML is not supported yet.

1. In your identity provider, create a web application. Set its redirect URI to `https://unhandled.net/sso/callback` and grant the `openid`, `email` and `profile` scopes.
2. In **Settings, Identity**, enter the issuer URL, the client ID, the client secret and your email domain, then **Save**. The secret is stored encrypted and never shown again.
3. Add the TXT record the page shows, `_unhandled-sso.<domain>` with the value `unhandled-sso=<token>`, then select **Verify domain**.
4. Select **Test sign-in with SSO** to sign in once through the identity provider.

People sign in at `/sso` with their work email, or with **Sign in with SSO** on the sign-in page.

### Who the identity provider can sign in

The identity provider's subject identifies the person. An email address links an Unhandled account only in these cases:

- The address is at the verified domain and the identity provider marks it verified. A new address gets an account and joins the organization as a member. An existing account is linked. If that account never verified its address, its password, passkeys, GitHub link, sessions and MCP tokens are revoked first, since someone else may have registered it.
- A member who is already signed in to Unhandled starts SSO for the organization. This links contractors and others whose address is at another domain.

Anyone else is refused. An identity provider can never sign in an address outside its verified domain.

### Require single sign-on

With **Require single sign-on** on:

- Members whose address is at the verified domain, and members who linked an identity provider account, cannot sign in with a password, a passkey or GitHub. Unhandled sends them to the identity provider instead.
- Every member must have signed in through the identity provider in the current browser session before they can open the organization. Other requests return `403`.
- The MCP consent page, where you connect Claude Code, Cursor or another agent, sends anyone who belongs to the organization through the identity provider first.

You can only turn it on after the domain is verified and you signed in through the identity provider in this session, so a broken setup cannot lock everyone out. A session that comes back from **Remember me** has to sign in through the identity provider again.

The issuer must use `https://` and resolve to a public address. A self-hosted instance can exempt hosts, such as a local Keycloak, with the `SSO_TRUSTED_HOSTS` environment variable, a comma-separated list of `host:port` values.

Single sign-on is available on every plan.

## SCIM

Unhandled serves SCIM 2.0 Users at `https://unhandled.net/scim/v2`. It implements the parts of RFC 7644 that Okta and Microsoft Entra ID use to provision users.

1. Open **Settings, Identity** and select **Create SCIM token**. Copy the token. It is shown once.
2. In your identity provider, set the base URL to `https://unhandled.net/scim/v2` and the authentication to a bearer token with that value.
3. Assign people to the app. Each `userName` must be their email address.

Each organization has one token. **Rotate** replaces it, and the old one stops working at once. **Revoke** deletes it.

### Provisioning

- Someone who is already a member is linked to the SCIM user.
- Anyone else gets the same emailed invitation an admin would send, as a member. They join when they accept it, so Unhandled never takes an address on the identity provider's word.
- Identity providers look users up with `filter=userName eq "..."` or `filter=externalId eq "..."`. Other filters return `400 invalidFilter`.

### Deprovisioning

Setting `active` to `false` (with `PATCH` or `PUT`) or deleting the user removes them from the organization at once and revokes, in every organization they belong to:

- their MCP connections and OAuth grants, including unredeemed authorization codes;
- the agent identities they created;
- their browser sessions.

A pending invitation for that address is withdrawn. The organization's last admin cannot be deprovisioned, and that request returns `409`.

Deprovisioning only reaches people who are members of the organization that owns the token. A token never reads or changes another organization's SCIM users.

### Supported operations

| Request | Result |
|---|---|
| `GET /ServiceProviderConfig` | Patch and filter supported, bulk, sort and ETags not |
| `GET /Users` | Paged with `startIndex` and `count`, at most 100 per page |
| `POST /Users` | Provisions, `409 uniqueness` when the `userName` exists |
| `GET /Users/{id}` | The user, or `404` |
| `PUT /Users/{id}` | Replaces `userName`, `externalId`, `displayName` and `active` |
| `PATCH /Users/{id}` | `add`, `replace` and `remove` on those attributes, with or without a `path`. String booleans such as `"False"` are accepted |
| `DELETE /Users/{id}` | Deprovisions and deletes, `204` |

Requests are limited to 300 a minute per token.

## Related

- [Access and permissions](/docs/features/access-and-permissions): Roles, teams and invitations.
- [Security model for AI agents](/trust/ai-agents): How agents connect and what revokes them.
