---
title: "SCIM provisioning from Entra ID and Okta, and single sign-on for the organisation"
url: https://www.hmailserver.co.uk/topic/28267/scim-provisioning-from-entra-id-and-okta-and-single-sign-on-for-the-organisation
author: "Progressiverobot"
published: 2026-09-27T19:49:13.731Z
updated: 2026-09-27T19:49:13.731Z
category: "REST API & integrations"
tags: ["rest-api","security","administration","portal","control-deck"]
site: hMailServer Community Forum
---

# SCIM provisioning from Entra ID and Okta, and single sign-on for the organisation

An organisation that keeps its people in an identity provider wants two things of its mail server: mailboxes that follow the directory, and a sign-in that goes through it. Since 6.3.4 hMailServer has both. SCIM 2.0 at `/scim/v2` lets Microsoft Entra ID or Okta make, rename, suspend and remove accounts, with their aliases and distribution lists. Single sign-on, by OpenID Connect or SAML 2.0, opens the webmail at `/portal` and the Control Deck at `/`. **The one thing to know first:** they answer different questions and take different credentials. SCIM answers to an API key whose role holds `provision` and to nothing else. Single sign-on opens the two pages only: IMAP, POP3 and SMTP still take the account's password and its application passwords.

Passkeys and account recovery are covered in [the topic on signing in](/topic/28253).

### How the pieces fit

| Piece | Direction | What it does |
|---|---|---|
| LDAP directory authentication | This server asks the directory | Checks a password when somebody signs in |
| SCIM provisioning | The identity provider calls this server | Says what changed, as it changes |
| Single sign-on | The browser goes to the identity provider and back | Signs a person in to the webmail or the Control Deck |
| `oidc_auto_provision`, `saml_auto_provision` | In passing, at sign-in | Makes a mailbox for whoever arrives with a verified address, and never touches it again |

SCIM is the deliberate version of auto-provisioning. The two work together: SCIM keeps the mailboxes, and single sign-on is how the people it made sign in to them.

**Where the settings are.** The single sign-on settings are the REST API's and the Control Deck's. There is no COM property and no Control Panel page for them, on Windows or on Linux.

### SCIM: the key and the permission

A SCIM request is a request of the REST listener. `RestApiPort` has to be on and the listener reachable from the identity provider's service over TLS, with a certificate it trusts (`RestApiCertificateFile`, or the ACME certificate). A body is at most 64 KB, and the key's requests count against the listener's budget of 200 in ten seconds per credential, beyond which the answer is `429` with `Retry-After`.

The key's role must hold `provision`. The built-in `serveradmin` role holds it, so every key issued with `"scope":"full"` provisions; no other built-in role does, so a read-only key is answered `403`. The way to issue a key that provisions and does nothing else is a custom role:

```bash
curl -u Administrator:your-admin-password -H "Content-Type: application/json" \
     -d '{"name":"entra-provisioning","description":"Microsoft Entra ID","permissions":"provision"}' \
     https://mail.example.com:8443/api/v1/roles

curl -u Administrator:your-admin-password -H "Content-Type: application/json" \
     -d '{"label":"Entra provisioning","role":"entra-provisioning"}' \
     https://mail.example.com:8443/api/v1/apikeys
```

The second request, `POST /api/v1/apikeys`, answers the `hmapi_...` token once, and that token is the identity provider's secret. `provision` is needed for SCIM's reads as well as its writes.

Both requests present the administrator password, which works while no second factor is enrolled on it. With one enrolled, sign in once at `POST /api/v1/session` with the code in `X-hMailServer-OTP`, and send both requests with the `hmailsession` cookie and `X-Requested-With: hMailServer`: since 6.3.4 a one-time code is taken once, so a second request carrying the same code within the same half minute is refused with `401`.

**Every other credential is refused.** The administrator password, an account's password and a browser session are answered `401` on `/scim/`, in SCIM's shape, with `WWW-Authenticate: Bearer`.

**A key confined to named domains** (`"domains":"example.com"`) provisions those domains and learns nothing about the rest: listings are filtered, an account elsewhere is the same `404` as one that is not there, and an address elsewhere is refused `400 invalidValue`.

**A key may not change or remove an account whose authority it does not take in**, and is answered `403`. A key whose role holds `provision` alone takes in no administrator at all: not a domain administrator, not a help desk, not an auditor. A full key confined to named domains takes in an administrator of those domains and nobody else. A full key issued for every domain takes in every account.

### SCIM: the routes and the mapping

| Route | What it does |
|---|---|
| `GET /scim/v2/ServiceProviderConfig` | What this server supports: patch, filter (200 at most), changePassword and etag; not bulk and not sort |
| `GET /scim/v2/Schemas`, `GET /scim/v2/ResourceTypes` | The User and Group schemas, listing only what this server keeps; the two resource types |
| `GET`, `POST /scim/v2/Users` | The accounts: listed and filtered, or one made |
| `GET`, `PUT`, `PATCH`, `DELETE /scim/v2/Users/{id}` | One account: read, replaced, changed, removed |
| `GET`, `POST /scim/v2/Groups` | The distribution lists: listed and filtered, or one made |
| `GET`, `PUT`, `PATCH`, `DELETE /scim/v2/Groups/{id}` | One list: read, replaced, changed, removed |

Every answer under `/scim/` is `application/scim+json`, the refusals included.

**A User is an account.**

| SCIM | This server |
|---|---|
| `id`, `userName` | The account's address. The id is the address, so a rename changes the id. |
| `name.givenName`, `name.familyName` | The first and last names, sixty characters each |
| `displayName` | The two names composed. Written alone to an account that already has a name, it is accepted and not kept. |
| `active` | The account's switch. `false` suspends it and deletes nothing. |
| `emails` | The address, `type` `work` and `primary`, and every alias whose value is the address, `type` `other` |
| `password` | Write-only. Judged by the password policy, the reuse history and, when it is on, the breach check. |
| `externalId` | The identity provider's own id for the person, at most 255 characters (schema 6096) |

`meta.created` and `meta.lastModified` are not answered, because the tables keep no such times. **A Group is a distribution list**: `id` and `displayName` are the list's address, and `members` are its members. The sharing groups at `/api/v1/groups` are folder-permission principals and are not SCIM groups.

Attributes the schemas have and this server does not keep, such as `title`, `phoneNumbers` and the enterprise extension's `department` and `manager`, are accepted and not kept. A misspelt attribute or path is refused by name.

### SCIM: what each operation does

**Making a user.** `POST /scim/v2/Users` needs a `userName` at a domain this server hosts and the key reaches. A body with no `password`, which is what Entra sends, makes the account with thirty-two random bytes for one, which nobody is ever told. An address something already has is `409 uniqueness`.

**Filters.** These and no others: `userName eq`, `userName co`, `userName sw`, `externalId eq`, `emails.value eq` and `active eq`. Any other filter is `400 invalidFilter`. `count` is at most 200, and `sortBy` and `sortOrder` are ignored. Groups filter on `displayName eq` only.

**Changing.** `PUT` takes the body a `POST` takes, and an attribute it leaves out is left as it is. `PATCH` takes a `PatchOp`, with `op` in any case, as Entra capitalises it. `If-Match` is honoured on `PUT`, `PATCH` and `DELETE`; a version other than the current one is `412`. A change is judged whole before anything is written.

**Suspending.** `active` `false` refuses every sign-in from the next attempt, refuses mail to the account, ends its browser sessions and drops its live IMAP, POP3 and SMTP connections. The mailbox, the aliases and the lists are untouched.

**Renaming.** A new `userName` renames the account through [the mailbox move](/topic/28244). It is refused `409` while the mailbox is in use. The id changes, the old id is `404` from then on, and the old address is kept as an alias until a later request that sends `emails` leaves it out.

**Every alias of the account is part of `emails`**, including one an administrator made. An identity provider that replaces `emails` removes the aliases it does not list. Where aliases are managed on this server and not in the directory, do not map `emails` beyond the work address.

**Removing.** `DELETE /scim/v2/Users/{id}` removes the account and its mailbox, every alias `emails` answered for it, and its row in every distribution list. It answers `204`. Nothing of it can be put back but from a backup. Entra sends `active` `false` straight away and the `DELETE` thirty days after a user is soft-deleted, so the suspension is the safe first step.

Five diagnostics in the error log, `HM7220` to `HM7224`, name what a SCIM write left behind when it failed part way. Every SCIM write is on the audit trail, each row naming `key:<id>`.

### Setting up Microsoft Entra ID for SCIM

1. Issue the key as above and note the token.
2. In the Entra admin centre, **Enterprise applications** > **New application** > **Create your own application** > *Integrate any other application you don't find in the gallery (Non-gallery)*.
3. **Provisioning** > **Automatic**. **Tenant URL**: `https://<host>:<port>/scim/v2`, where the port is `RestApiPort`. **Secret token**: the `hmapi_...` token. **Test connection** asks for a user that does not exist and expects an empty list, which is what this server answers.
4. **Mappings** > *Provision Microsoft Entra ID Users*: map `userName` from `userPrincipalName` where that is the mailbox address, or from `mail` where it is not. `emails[type eq "work"].value` must be the same address as `userName`. Keep `active`, `name.givenName`, `name.familyName`, `displayName` and `externalId`, and delete the mappings this server does not keep.
5. **Mappings** > *Provision Microsoft Entra ID Groups*, for mail-enabled groups that should be distribution lists: map `displayName` from `mail`, keep `members`, and delete the mapping of `externalId`.
6. Assign the users and groups, and switch **Provisioning Status** on.

### Setting up Okta for SCIM

1. Issue the key as above.
2. **Applications** > **Create App Integration** > *SAML 2.0* or *SWA*, then on **General** enable **SCIM provisioning**.
3. **Provisioning** > **Integration**: **SCIM connector base URL** `https://<host>:<port>/scim/v2`; **Unique identifier field for users** `userName`; **Supported provisioning actions**: *Import New Users and Profile Updates*, *Push New Users*, *Push Profile Updates*, *Push Groups*; **Authentication Mode** *HTTP Header*, and the token in **Authorization**.
4. **Provisioning** > **To App**: enable *Create Users*, *Update User Attributes* and *Deactivate Users*.
5. **Push Groups** sends a group under its Okta name as `displayName`, and here that is the list's address, so a push of `Engineering` is `400 invalidValue`. Name each pushed group with the list's address, or leave *Push Groups* off.

Okta sends `emails` with the primary address alone, so an alias made on this server is removed by Okta's next update. [Scim.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/Scim.md) gives no steps for Google Workspace: at the time it was written, a custom application there could not be pointed at a SCIM endpoint.

### Single sign-on with OpenID Connect

Register this server at the issuer as an authorization-code client with PKCE, with the redirect URI `<oidc_redirect_base_url>/portal/oidc/callback`.

| Setting | What it is |
|---|---|
| `oidc_issuer` | For example `https://login.example.com`. The discovery document is read from `<issuer>/.well-known/openid-configuration` and cached for an hour. Empty offers no single sign-on at all. |
| `oidc_client_id` | The client id this server is registered under |
| `oidc_client_secret` | Its secret, stored protected and never emitted. Empty for a public client. |
| `oidc_scopes` | `openid email` by default |
| `oidc_redirect_base_url` | The address the webmail is reached at from outside. Empty falls back to `import_oauth_redirect_base_url`. |
| `oidc_auto_provision` | Whether a verified address with no mailbox here gets one. Off. |
| `oidc_administrators` | The addresses that open the Control Deck as the administrator. Empty means single sign-on reaches the webmail only. |

The flow is `GET /portal/oidc/start[?deck=1]` to the issuer and `GET /portal/oidc/callback` back. The callback verifies the `id_token`: its signature against the issuer's published keys (RS256 and ES256 only), `iss`, `aud`, `exp`, `iat` and `nbf` with a minute of skew, and the `nonce`. It then takes `email`, and only when `email_verified` says the issuer verified it.

With `oidc_issuer` set, both sign-in pages draw a **Sign in with** button naming the issuer's host. What they draw is decided by `GET /api/v1/signin-methods`, which needs no credential and says nothing about who exists here. With SAML set up it answers `saml: true` and `saml_label`, and both pages draw a second button.

**The callback does not make the session.** It mints a one-use ticket, good for two minutes, and the page hands it to `POST /api/v1/session`, which is where every browser session is made. The ticket is bound to the browser by an `hmsso` cookie, and `POST /api/v1/session` refuses a ticket that arrives without it.

**Mapping.** The verified address is looked up as a mailbox. With auto-provisioning off, the default, a sign-in with no mailbox is refused. With it on, a mailbox is made, but only at a domain this server already has, and with a random password nobody is told. For the Control Deck, the address must be in `oidc_administrators`. An issuer that says somebody is an administrator is not obeyed.

### Single sign-on with SAML 2.0

For identity providers that speak only SAML 2.0: ADFS above all, and an Entra ID or Okta application set up the SAML way. It ends in the same ticket, the same `POST /api/v1/session` and the same mapping rules.

| Setting | What it is |
|---|---|
| `saml_idp_entity_id` | The identity provider's entity ID. Empty, the default, offers no SAML sign-in at all. |
| `saml_idp_sso_url` | Its sign-in URL for the HTTP-Redirect binding |
| `saml_idp_certificate` | Its signing certificate in PEM, or two while it rolls its key over. Write-only. |
| `saml_sp_entity_id` | This server's entity ID. Empty means `<base URL>/saml`. |
| `saml_email_attribute` | The attribute the address is read from when the NameID is not an email address |
| `saml_require_signed_assertion` | Whether the Assertion must carry its own signature. On by default. |
| `saml_auto_provision`, `saml_administrators` | As the OpenID Connect settings of the same names |
| `saml_button_label` | The words on the button: **Sign in with SAML** by default |

The base URL is OpenID Connect's: `oidc_redirect_base_url`, falling back to `import_oauth_redirect_base_url`. From it follow the entity ID `<base URL>/saml`, the ACS URL `<base URL>/portal/saml/acs` and the metadata at `<base URL>/saml/metadata`.

| Identity provider | What to enter there | What to copy back |
|---|---|---|
| Microsoft Entra ID | *Identifier (Entity ID)* `<base URL>/saml`; *Reply URL* `<base URL>/portal/saml/acs`; token encryption off | *Login URL* to `saml_idp_sso_url`; *Microsoft Entra Identifier* to `saml_idp_entity_id`; *Certificate (Base64)* to `saml_idp_certificate` |
| Okta | *Single sign-on URL* `<base URL>/portal/saml/acs`; *Audience URI (SP Entity ID)* `<base URL>/saml`; *Name ID format* *EmailAddress*; *Assertion Encryption* *Unencrypted* | *Identity Provider Single Sign-On URL* to `saml_idp_sso_url`; *Identity Provider Issuer* to `saml_idp_entity_id`; *X.509 Certificate* to `saml_idp_certificate` |
| ADFS | A claims-aware relying party trust, imported from `<base URL>/saml/metadata` or entered by hand; *E-Mail-Addresses* sent as *E-Mail Address* | `https://<adfs host>/adfs/ls/` as `saml_idp_sso_url`; the *Federation Service Identifier* as `saml_idp_entity_id`; the *Token-signing* certificate as `saml_idp_certificate` |

**The flow.**

```
GET  /portal/saml/start[?deck=1]   to the identity provider, with an AuthnRequest,
                                   and the hmsaml cookie
POST /portal/saml/acs              back from it, with the Response
GET  /portal/saml/finish           on from the ACS, where the cookie is asked for
GET  /saml/metadata                this service provider, for the identity provider
```

The ACS takes a form of at most 256 KB; a larger body is answered `413`. The signature is verified against the certificates an administrator stored and never a key the message carries. The signature method must be `rsa-sha256` or `ecdsa-sha256`, and SHA-1 is refused. A Response is taken only when it answers a request this server issued, not yet answered, with the same RelayState. The checks refuse signature wrapping, a replayed Response or assertion, a Response nobody asked for and an encrypted assertion; comment injection in the `NameID` gains nothing, because the address read is the whole of what was signed.

**A `saml_idp_certificate` that cannot be read stops every SAML sign-in.** The settings refuse one, so it comes only from a database edited by hand; `HM7320` in the error log then names the setting to paste again.

**A SAML identity provider says nothing like `email_verified`.** The attribute the address is read from must be one the directory controls, such as `mail` or the user principal name, and never one a person can set themselves. This server cannot check that.

### What it does not do

- **SCIM:** no bulk, no sorting and no `POST .search` (`/Bulk`, `/Me` and `POST .search` answer `501`); no enterprise user extension; no groups but distribution lists; not a SCIM client. `externalId` is shown by nothing but SCIM and COM's `Account.ExternalId`, which is Windows-only.
- **Single sign-on, both kinds:** no logout in either direction, so an account disabled at the identity provider keeps a session it already started until that session's own ceiling ends it. One OpenID Connect issuer and one SAML identity provider. Nothing is enforced at IMAP, POP3 and SMTP.
- **OpenID Connect:** no claims mapping. The `email` claim and nothing else.
- **SAML:** no encrypted assertions, no signed requests, no metadata fetch, no artifact binding and no sign-in started by the identity provider.

### Where to read more

- The operator documents: [Scim.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/Scim.md), [PasskeysAndSingleSignOn.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/PasskeysAndSingleSignOn.md) and [Roles.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/Roles.md).
- The wiki: [The REST API](https://gitlab.com/hmailserver/hmailserver/-/wikis/The-REST-API) and [APIs Reference](https://gitlab.com/hmailserver/hmailserver/-/wikis/APIs-Reference).
- The public documentation: [chapter 24, The REST API](https://www.progressiverobot.com/hmailserver-documentation/#24-the-rest-api) and [chapter 36, The Control Deck and the webmail portal](https://www.progressiverobot.com/hmailserver-documentation/#36-the-control-deck-and-the-webmail-portal).
- On this forum: passkeys, recovery and the rest of sign-in are in [Signing in: passwords, second factor, passkeys, single sign-on](/topic/28253); LDAP sign-in and directory synchronisation are in [Active Directory and LDAP sign-in](/topic/28263); [The Control Deck](/topic/28192); the [6.3.4 announcement](/topic/28218); and [the REST API guide](/topic/28264).
