> For the complete documentation index, see [llms.txt](https://mercure-technologies.gitbook.io/expo-open-ota/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mercure-technologies.gitbook.io/expo-open-ota/security/single-sign-on-sso.md).

# Single sign-on (SSO)

{% hint style="warning" %}
Single sign-on is an Enterprise feature and requires [control-plane mode](/expo-open-ota/controle-plane-mode/overview.md) and an active Enterprise license (see [open-core-and-licensing](/expo-open-ota/open-core-and-licensing.md)). Stateless deployments cannot use it, because the configuration lives in the database. If the license expires, the stored configuration is kept but sign-ins fall back to email and password until a valid license is active again.
{% endhint %}

Single sign-on lets your team open the dashboard with your company identity provider instead of a dedicated password. Expo Open OTA implements it as a standard OpenID Connect client (authorization code flow with PKCE), so it works with Microsoft Entra ID, Okta, Google Workspace, Keycloak and any other provider that exposes an OIDC issuer. You configure everything from the dashboard itself, on the **SSO** page of the **Access & Security** sidebar section: there are no environment variables to set, and a change saved on one replica applies to all of them immediately.

## How it works

When SSO is configured and enabled, the login page grows a **Continue with your provider** button (you choose its label). Clicking it sends the browser to your identity provider; once the provider has authenticated the user, the server verifies the response and opens a regular dashboard session, identical to the one a password sign-in produces.

Accounts are provisioned just in time. The first time someone signs in through SSO, the server creates a **member** account for them automatically, so there is nothing to pre-create: publishing access to the dashboard becomes a matter of who your identity provider lets in. If that is wider than you want, [Require admin approval](#require-admin-approval) puts a human decision in front of every new account. If you want to make someone an admin, promote them afterwards from the [Users](/expo-open-ota/dashboard/users.md) page.

Two details make this robust in practice. If an account with the same email address already exists (for example a member you created by hand before adopting SSO), the first SSO sign-in links to that account instead of duplicating it, and the account keeps its role and its password. And once linked, an identity is keyed by the provider's stable subject identifier rather than by email, so a user whose address changes at the provider keeps their account and their permissions. Because that email match can hand over an existing account, the server only trusts an address the provider has verified; see [How emails are trusted](#how-emails-are-trusted) below.

## What changes while SSO is active

Activating SSO also enforces it, following the same convention as Grafana or GitLab:

* **Members must sign in through SSO.** A member who presents a correct password receives an explicit message telling them to use SSO instead. Their password is not deleted; it simply becomes usable again if you ever turn SSO off.
* **Admins keep the password sign-in** as a break-glass access. If your identity provider is down or the configuration breaks, an admin can always sign in with email and password, open the SSO page and turn it off, which instantly restores password sign-in for everyone. There is no scenario in which the dashboard locks itself out.
* **Manual account creation is disabled.** The Users page stops offering the create button, because accounts arrive by provisioning on first sign-in. Promotion, demotion and deletion work unchanged.

Accounts created by SSO provisioning have no password at all, so they can only ever sign in through SSO. Deleting such a user works as usual; if the same person signs in again later, a fresh member account is created for them.

## Set it up

{% stepper %}
{% step %}

#### Register the application at your provider

In the dashboard, open **Access & Security → SSO**. The page displays a read-only **Redirect URI**, derived from your server's `BASE_URL` (it looks like `https://your-server/auth/sso/callback`). Copy it, then create an OIDC web application at your identity provider using that redirect URI. The provider gives you back a client id and a client secret. The sections below walk through the exact clicks for the main providers.
{% endstep %}

{% step %}

#### Fill in the form

Back in the dashboard, enter the **Issuer URL** of your provider, the **Client ID** and the **Client secret**, and pick the **Button label** your team will see on the login page (for example "Microsoft"). The scopes default to `openid profile email`, which is right for almost every provider.
{% endstep %}

{% step %}

#### Save and test

Press **Save and test connection**. The server performs a live OIDC discovery against the issuer before storing anything: it fetches `/.well-known/openid-configuration` and, if the provider answers with an error, that error is shown to you as is, so a typo in the issuer or a firewall problem surfaces immediately instead of at the first sign-in. Nothing is saved until discovery succeeds, and SSO sign-in goes live for your team on the first successful save.
{% endstep %}

{% step %}

#### Try it

Open the login page in a private window. The SSO button is there; sign in with a test account and check that it appears as a member on the Users page. Once a configuration exists, the page shows an **Enable SSO sign-in** switch, so you can turn SSO off at any time without losing the configuration.
{% endstep %}
{% endstepper %}

## Restrict who can sign in

By default, every account your identity provider authenticates may sign in and get a member account, which usually means your whole tenant. The **Who can sign in** section of the page adds two optional allowlists on top of that; when both are set, an account must satisfy both.

* **Allowed email domains** compares the domain of the user's email address against the list. It works with every provider, because the email claim is universal.
* **Allowed groups** compares the groups your provider puts in the id\_token against the list. The claim that carries them is configurable (**Groups claim** under Advanced settings, `groups` by default), because providers name it differently. A user must belong to at least one listed group.

Keep in mind that the strongest control remains the one at the provider itself: restricting who can obtain a token for the application ("Assignment required" on Entra, app assignments on Okta) stops unwanted users at the provider's door, before they ever reach your server. Use the in-app restrictions as a complement, or when the provider offers nothing better, which is the case for Google:

| Provider           | Groups in the id\_token                                                                                                                                                                              |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Microsoft Entra ID | Yes, opt-in through **Token configuration**. Groups are sent as **object IDs** (GUIDs), so that is what you put in the allowlist. Users in more than roughly 200 groups overflow the claim entirely. |
| Okta               | Yes, with a groups claim filter on the app integration. Group names are sent; add `groups` to the scopes in the form.                                                                                |
| Google Workspace   | No. Google never puts groups in tokens, so restrict by email domain and turn on [Require admin approval](#require-admin-approval) instead.                                                           |
| Keycloak           | Yes, by adding a **Group Membership** mapper to the client.                                                                                                                                          |

## Require admin approval

The allowlists above answer the question "does this person belong to something we trust?". Some providers cannot answer it. The **Require admin approval** switch, at the bottom of **Who can sign in**, replaces that answer with a human decision.

With the switch on, a first SSO sign-in still creates the account, but the account lands disabled and that sign-in is refused with a message saying it is waiting for an administrator. The account then shows up on the [Users](/expo-open-ota/dashboard/users.md) page with a **Pending** status, and any admin lets the person in by turning its switch on. Nothing else is needed: the next sign-in goes through like any other, and the account is never asked to be approved again.

This is the recommended setting for Google Workspace, and more generally for any provider that does not send group claims. Google never puts groups in the id\_token, so the group allowlist cannot work there. The email domain allowlist keeps outsiders out, but it still lets in every account of your Workspace, which is usually your whole company rather than the few people who publish updates. Requiring approval closes that gap without asking you to maintain anything at the provider.

Accounts that already exist are never caught by this. Turning the switch on revokes nobody, and an account that existed before SSO keeps its access when its first SSO sign-in links to it, so you can adopt approval on a live deployment without locking your team out.

The same switch on the Users page also works in reverse: turning it off on an active user revokes their access without deleting the account, which keeps its history and lets you restore it later. A revocation takes effect on that user's next token refresh at the latest. You cannot disable your own account, and the last enabled admin can never be disabled, so the dashboard always keeps at least one way in.

## How emails are trusted

The email address your provider asserts does a lot of work here: it decides whether an account passes the **Allowed email domains** list, and it is how a first sign-in is matched to an account that already exists. Because that match can hand over an existing account, Expo Open OTA only uses the address when the provider vouches for it.

An OpenID Connect id\_token carries the `email` claim and, next to it, an `email_verified` flag that says whether the provider actually confirmed the user owns that mailbox. By default a sign-in may only use the email when `email_verified` is true. If it is false, or the provider does not send the flag at all, the sign-in is refused and no account is created or linked.

This closes a real account-takeover path. On a provider where someone can set an arbitrary email on their own account without confirming it, or on a multi-tenant setup where an attacker controls their own tenant, that person could assert `admin@yourcompany.com` as an unverified address. Without the check, their first sign-in would link to your existing admin account and hand it over, no password required. The `email_verified` flag is exactly the signal that prevents this, so the server respects it rather than ignoring it.

Well-behaved providers make this invisible: Google, Okta and Keycloak send `email_verified: true` for real accounts, so nothing changes for them. The exception is Microsoft Entra ID, which does not emit `email_verified` at all. For Entra you turn on **Trust emails from this provider** in the **Who can sign in** section, which lifts the requirement.

{% hint style="danger" %}
Only enable **Trust emails from this provider** for a provider you control end to end: a single tenant where users cannot set their own email address, with **Assignment required** turned on so outsiders cannot obtain a token in the first place. Never enable it for a multi-tenant issuer (for example `https://login.microsoftonline.com/common`), where anyone can bring an account carrying an unverified address of their choice.
{% endhint %}

## Provider guides

### Microsoft Entra ID

{% stepper %}
{% step %}

#### Create the app registration

In the [Entra admin center](https://entra.microsoft.com), go to **Identity → Applications → App registrations → New registration**. Keep the supported account types on "Accounts in this organizational directory only", choose the **Web** platform and paste the redirect URI from the dashboard.
{% endstep %}

{% step %}

#### Create a client secret

In the registration, open **Certificates & secrets → New client secret**. Copy the secret's **Value** right away: Entra only shows it once.
{% endstep %}

{% step %}

#### Fill in the dashboard form

The **Issuer URL** is `https://login.microsoftonline.com/{tenant-id}/v2.0`, where the tenant id ("Directory (tenant) ID") and the client id ("Application (client) ID") are both on the registration's **Overview** page.
{% endstep %}

{% step %}

#### Add the email claim

Entra does not include the `email` claim by default. In **Token configuration → Add optional claim**, pick token type **ID** and check `email`. Without it, the server falls back to `preferred_username`, which carries the address for standard cloud accounts but not for every directory setup, so adding the claim explicitly is the reliable path. If you restrict by group, add the **groups claim** (security groups) from the same screen, and remember the allowlist entries are group object IDs.
{% endstep %}

{% step %}

#### Require assignment

In **Entra admin center → Enterprise applications**, open your application, go to **Properties** and set **Assignment required?** to **Yes**, then assign the users or groups that should have access under **Users and groups**. Without this, every account of your tenant can sign in (subject to the in-app restrictions you configured).
{% endstep %}

{% step %}

#### Trust the provider's emails

Entra does not send the `email_verified` flag, so with the default settings every Entra sign-in is refused as unverified. On the **SSO** page, in **Who can sign in**, turn on **Trust emails from this provider**. Do this only because you completed the previous step: a single tenant with **Assignment required** is exactly the case where the addresses are trustworthy. See [How emails are trusted](#how-emails-are-trusted).
{% endstep %}
{% endstepper %}

### Okta

{% stepper %}
{% step %}

#### Create the app integration

In the Okta admin console, go to **Applications → Create App Integration**, choose **OIDC - OpenID Connect** and **Web Application**. Paste the redirect URI from the dashboard into **Sign-in redirect URIs**, and assign the users or groups that should have access (Okta requires assignments by default, which is exactly what you want).
{% endstep %}

{% step %}

#### Fill in the dashboard form

The **Issuer URL** is your Okta domain, for example `https://acme.okta.com`. The client id and client secret are on the app's **General** tab. Okta sends `email_verified`, so you do not need to trust unverified emails.
{% endstep %}

{% step %}

#### Send the groups claim (optional)

If you restrict by group, open the app's **Sign On** tab, edit the **OpenID Connect ID Token** section and set a **Groups claim filter** (for example claim name `groups`, filter "Matches regex" `.*`). Then add `groups` to the **Scopes** field under Advanced settings in the dashboard.
{% endstep %}
{% endstepper %}

### Google Workspace

{% stepper %}
{% step %}

#### Create the OAuth client

In the [Google Cloud console](https://console.cloud.google.com), go to **APIs & Services → Credentials → Create credentials → OAuth client ID** and choose the **Web application** type. Paste the redirect URI from the dashboard into **Authorized redirect URIs**. If the console asks you to configure the consent screen first, set the user type to **Internal** so only accounts of your Workspace organization can ever sign in.
{% endstep %}

{% step %}

#### Fill in the dashboard form

The **Issuer URL** is `https://accounts.google.com`. Use the client id and client secret the console generated. Google only ever returns an address you own and marks it verified, so leave **Trust emails from this provider** off.
{% endstep %}

{% step %}

#### Restrict by email domain

Google does not put group membership in tokens, so the group allowlist cannot work with Google. Add your Workspace domain to **Allowed email domains** instead; combined with the Internal user type, this gives you two independent fences.
{% endstep %}

{% step %}

#### Require admin approval

Because there are no groups to filter on, the domain allowlist is the only automatic fence available, and it lets in every account of your Workspace. Turn on **Require admin approval** so a new account waits on the [Users](/expo-open-ota/dashboard/users.md) page until an admin approves it. This is the recommended configuration for Google, and it is what turns "everyone at the company" into the people you actually chose. See [Require admin approval](#require-admin-approval).
{% endstep %}
{% endstepper %}

### Keycloak

{% stepper %}
{% step %}

#### Create the client

In the Keycloak admin console, select your realm, go to **Clients → Create client**, keep the **OpenID Connect** type, pick a client id and turn **Client authentication** on (that is what makes it a confidential client with a secret). On the next screen, paste the redirect URI from the dashboard into **Valid redirect URIs**.
{% endstep %}

{% step %}

#### Fill in the dashboard form

The **Issuer URL** is `https://your-keycloak-host/realms/{realm-name}`. The client id is the one you chose; the secret is on the client's **Credentials** tab. Keycloak sends `email_verified`, so keep **Trust emails from this provider** off unless your realm lets users register with unconfirmed addresses.
{% endstep %}

{% step %}

#### Send the groups claim (optional)

If you restrict by group, open your client's **Client scopes**, select its dedicated scope and add a mapper of type **Group Membership** with claim name `groups`, added to the ID token. Turn **Full group path** off, otherwise Keycloak sends `/engineering` instead of `engineering` and the allowlist entries must match that format.
{% endstep %}
{% endstepper %}

## Troubleshooting

**Saving fails with a provider error.** That is the live discovery doing its job: the message is the provider's own answer (a DNS failure, a 404 on `/.well-known/openid-configuration`, an unreachable host). Fix the issuer or the network path and save again; nothing was stored.

**The sign-in says the account is waiting for approval.** **Require admin approval** is on and this account has not been approved yet. Open the [Users](/expo-open-ota/dashboard/users.md) page, find the account (it carries a **Pending** status) and turn its switch on; the person can sign in right away. The same message appears for an account whose access was revoked, which shows as **Disabled** there.

**The sign-in fails because the email is not verified.** The provider returned an address it has not marked as verified (`email_verified` is false or absent), so the server refused to use it. Google, Okta and Keycloak send the flag for real accounts; Microsoft Entra ID does not, so for Entra turn on **Trust emails from this provider** in **Who can sign in**, but only for a single tenant with **Assignment required** set. See [How emails are trusted](#how-emails-are-trusted).

**The sign-in comes back to the login page with an error.** The page shows one of a handful of messages: a sign-in cancelled at the provider, a provider that returned no usable email address (on Entra, add the optional `email` claim), an email the provider did not verify, an account waiting for an administrator to approve it, an account that does not pass the domain or group restrictions (check that the provider actually sends the groups claim you configured), or a generic failure. For the generic case the underlying reason is deliberately kept out of the browser and written to the server logs, so look there. A sign-in that sat on the provider's page for more than ten minutes also lands in the generic case; trying again is enough.

**The license expired.** SSO pauses: the button disappears and members regain password sign-in, so nobody is locked out. The configuration stays in the database and SSO resumes as soon as a valid license is activated again.

**The DB keys master key changed.** The client secret is encrypted with your server's master key, so after a key rotation the page shows a warning telling you the stored secret is no longer readable. Paste the secret again and save: it is re-encrypted under the current key. Sign-ins fail until you do, and password sign-in automatically reopens for everyone in the meantime.

**You run several replicas.** There is nothing to configure. The configuration is read from the database on every operation, and the state of an in-flight sign-in travels in a signed cookie, so any replica can complete a flow another replica started.

## See also

* [Users](/expo-open-ota/dashboard/users.md) to promote a provisioned member to admin, and to approve or revoke accounts.
* [Branch protection](/expo-open-ota/security/branch-protection.md) and [IP whitelisting](/expo-open-ota/security/ip-whitelisting.md), the other Enterprise access controls.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://mercure-technologies.gitbook.io/expo-open-ota/security/single-sign-on-sso.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
