# Single sign-on with OpenID Connect

> Sign users in through your identity provider with OpenID Connect, and turn provider groups into team roles. Settings for Keycloak, Entra ID, Okta and Google.

- Documentation: Guides
- Updated: 2026-11-04
- Source: https://queryproxy.com/docs/sso-oidc/
- Language: en-US
- Author: Muhammet Şafak

---
QueryProxy can sign users in through any OpenID Connect (OIDC) provider that
publishes a discovery document, and it can set their team roles from the groups
the provider reports. This page covers the settings, one example per provider,
the role map, and the behaviour you need to know before you switch password
login off.

> **Multi-factor authentication belongs to the provider.** A user who signs in
> through SSO is outside `QUERYPROXY_REQUIRE_2FA`: QueryProxy does not ask them
> for a second factor and cannot check which one the provider used. Enforce MFA
> in the identity provider, on the application you register for QueryProxy.

## How it works

QueryProxy uses the authorization code flow with PKCE. It checks `state` and
`nonce`, and it verifies the signature of the ID token against the provider's
published keys. SAML is not supported.

1. The login page shows a **Log in with SSO** button once both
   `QUERYPROXY_OIDC_ISSUER` and `QUERYPROXY_OIDC_CLIENT_ID` are set.
2. The user signs in at the provider and comes back to
   `{APP_URL}/auth/oidc/callback`.
3. QueryProxy finds or creates the user, applies the role map, and opens a
   session.

A user is identified by the issuer and the subject (`sub`) together, not by
e-mail address.

## Register QueryProxy at the provider

Create a confidential OIDC client (a "web application") and register one
redirect URI:

```text
https://queryproxy.example.com/auth/oidc/callback
```

It is `APP_URL` plus `/auth/oidc/callback`, and it must match exactly. Set
`APP_URL` to the address users type, normally the HTTPS URL behind your reverse
proxy.

Also register a **post-logout redirect URI**, `APP_URL` plus `/login`, for example
`https://queryproxy.example.com/login`. QueryProxy sends it when it ends the
provider's session at sign-out (see [Signing out](#signing-out)). If it is not
registered, the provider may show an error page instead of sending the user back.

Then set the client on the QueryProxy side:

```dotenv
QUERYPROXY_OIDC_ISSUER=https://idp.example.com/realms/queryproxy
QUERYPROXY_OIDC_CLIENT_ID=queryproxy
QUERYPROXY_OIDC_CLIENT_SECRET=...        # or QUERYPROXY_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc
```

`QUERYPROXY_OIDC_ISSUER` is the URL whose `/.well-known/openid-configuration`
describes the provider. Restart the containers after changing it.

| Variable | Default | Meaning |
| :-- | :-- | :-- |
| `QUERYPROXY_OIDC_ISSUER` | none | Issuer URL. SSO is on when this and the client ID are both set |
| `QUERYPROXY_OIDC_CLIENT_ID` | none | Client id registered at the provider |
| `QUERYPROXY_OIDC_CLIENT_SECRET` | none | Client secret. `QUERYPROXY_OIDC_CLIENT_SECRET_FILE` reads it from a file instead |
| `QUERYPROXY_OIDC_SCOPES` | `openid email profile` | Scopes requested, separated by spaces. Some providers need `groups` added |
| `QUERYPROXY_OIDC_GROUP_CLAIM` | `groups` | Claim that lists the user's groups. A dotted path such as `realm_access.roles` is followed when no claim has that exact name |
| `QUERYPROXY_OIDC_ROLE_MAP` | none | Comma-separated `group:team:role` entries; see [The role map](#the-role-map) |
| `QUERYPROXY_LOCAL_LOGIN` | `all` | Who may still sign in with a password: `all`, `admins_only` or `none` |

## What the ID token must carry

- **`sub`**, always.
- **`email`**, a valid address, the first time a provider identity signs in.
  Without it QueryProxy can neither link nor create an account, and the sign-in is
  refused. An identity it already knows (same issuer and `sub`) signs in without it.
- **A verified e-mail** for any account QueryProxy has not seen before (see
  below): `email_verified` set to `true`. Microsoft's ID token claims reference
  does not list that claim for Microsoft Entra ID; for it, `xms_edov` set to `true` counts as verified when
  `email_verified` is absent. An `email_verified` that is explicitly `false` is
  refused. If neither claim says the address is verified, it counts as not
  verified.
- `name` or `preferred_username` for the display name. If neither is present the
  part of the address before `@` is used.

### First sign-in, linking and verified e-mail

- **A new user is created on the first sign-in** (just-in-time provisioning),
  but only when the provider says the address is verified (`email_verified` is
  `true`, or `xms_edov` is `true` for Entra ID). A new user has no password and no team until the role map, or an
  administrator, gives them one. Nobody becomes a system administrator this
  way.
- **An existing local account with the same e-mail address is linked** to the
  provider, again only when the address is verified in the same way. The link is audited as
  `user.sso_linked`. After linking, the account keeps its password unless you
  restrict password login with `QUERYPROXY_LOCAL_LOGIN`.
- If the address is not verified, the sign-in is refused and the refusal is
  audited as `auth.sso_refused`.
- A local account that is already tied to a different provider subject is refused
  too, because a matching e-mail alone proves nothing. That refusal is written to
  the application log, not to the audit log.
- Suspended and deleted users cannot sign in through SSO either.

Connect only providers that verify the address a user claims. A provider that
marks unverified addresses as verified can take over a local account that has
the same e-mail address.

Changing `QUERYPROXY_OIDC_ISSUER` later does not carry existing SSO accounts
over: they are tied to the old issuer and are refused instead of being matched
by e-mail.

Audit events written by SSO: `auth.sso_login`, `auth.sso_refused`,
`user.sso_provisioned`, `user.sso_linked` and `user.role_synced`.

## The role map

`QUERYPROXY_OIDC_ROLE_MAP` turns provider groups into team roles at every
sign-in. Each entry is `group:team:role`; separate entries with commas:

```dotenv
QUERYPROXY_OIDC_ROLE_MAP=platform-dba:payments:dba,platform-dev:payments:developer,security:payments:auditor
```

- **`group`** is compared with the values in the group claim as an exact,
  case-sensitive string. The entry is split from the right, so a group name may
  contain colons.
- **`team`** is the slug or the name of an existing team. An entry that names a
  team that does not exist is skipped and logged.
- **`role`** is `dba`, `developer` or `auditor`. The system administrator flag
  cannot be set from a group.
- When several groups reach the same team, the highest role wins
  (`dba`, then `developer`, then `auditor`).

At every sign-in QueryProxy makes the user's memberships **in the teams the map
names** match the groups: it adds, changes and removes them, and writes each
change to the audit log as `user.role_synced`. Memberships in teams the map does
not name are not touched. That makes the provider the source of truth for those
teams: a role you change by hand there is overwritten at the user's next
sign-in.

Things to know:

- **Changes apply at the next sign-in.** QueryProxy does not poll the provider.
  To cut off someone at once, suspend them on the Users screen: that ends their
  open sessions.
- **A missing group claim means the sign-in is refused.** While a role map is set, an ID
  token without the group claim is refused, logged, and nothing is changed. An
  empty group list is a valid answer and is applied as such: the user loses the
  roles in the mapped teams.
- **Microsoft Entra ID group overage is not supported.** When a user belongs to
  too many groups, Entra ID leaves the `groups` claim out and points to Microsoft
  Graph instead; QueryProxy does not follow that pointer, so with a role map set
  those users are refused. See the Entra ID example for ways to stay under the
  limit.
- If the ID token lacks the group claim, or says nothing about whether the e-mail
  is verified (neither `email_verified` nor `xms_edov`), QueryProxy also reads the
  provider's userinfo endpoint for the claims that are missing.
- The role map is optional. Without it, users who sign in through SSO get no
  team until an administrator adds them.

## Provider examples

Only Keycloak is tested end to end by the project (the CI workflow signs in to a
Keycloak 26.8.1 container and checks the group sync). The other three follow each
provider's documentation; try them with a test user before you roll out.

### Keycloak

1. In your realm, create an OpenID Connect client that uses the standard
   (authorization code) flow and has a client secret. Set the valid redirect URI
   as above.
2. Add a **Group Membership** mapper to the client's dedicated scope, with the
   token claim name `groups`, and keep **Add to ID token** on.
3. Decide on **Full group path**. When it is on, group names are paths such as
   `/engineering/backend`, and the role map must use that exact text. Switching
   it off gives plain names.
4. Settings:

```dotenv
QUERYPROXY_OIDC_ISSUER=https://keycloak.example.com/realms/queryproxy
QUERYPROXY_OIDC_CLIENT_ID=queryproxy
QUERYPROXY_OIDC_CLIENT_SECRET=...
QUERYPROXY_OIDC_ROLE_MAP=dba-team:payments:dba,developers:payments:developer
```

Only users whose address Keycloak reports as verified (`email_verified`) can be
created or linked. Reference:
[Keycloak server administration guide](https://www.keycloak.org/docs/latest/server_admin/).

### Microsoft Entra ID

1. In **App registrations**, register a web application with the redirect URI
   above and create a client secret.
2. Use the v2.0 issuer of your tenant:
   `https://login.microsoftonline.com/<tenant-id>/v2.0`.
3. Under **Token configuration**, add a **groups claim**. By default Entra ID
   puts group **object IDs** in the claim, so the role map entries use those IDs
   (`0760b6cf-170e-4a14-91b3-4b78e0739963:payments:dba`).
4. Choose **Groups assigned to the application** instead of all groups to stay
   below the limit of 200 groups in a token; users beyond it get no `groups`
   claim and are refused while a role map is set.
5. Add the claims QueryProxy uses to verify the e-mail address. Under **Token
   configuration**, select **Add optional claim**, select the **ID** token type,
   select **xms_edov** and, if the token does not already carry an e-mail address,
   **email**, then select **Add**. (`xms_edov` is returned only when the `email`
   claim is present. QueryProxy requests the `email` scope by default; on the v2.0
   endpoint that scope also gives the `email` claim.) If the portal does not list
   `xms_edov`, add it to `optionalClaims.idToken` in the app's **Manifest** instead;
   Microsoft documents the manifest as the other way to configure optional claims.

```dotenv
QUERYPROXY_OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
QUERYPROXY_OIDC_CLIENT_ID=<application-client-id>
QUERYPROXY_OIDC_CLIENT_SECRET=...
QUERYPROXY_OIDC_ROLE_MAP=<group-object-id>:payments:dba
```

> **Without `xms_edov`, the first sign-in is refused.** Microsoft's ID token claims
> reference does not list an `email_verified` claim for Entra ID. If you do not add `xms_edov` to the ID token, QueryProxy
> cannot tell that the address is verified, and it neither creates nor links the
> account: the sign-in is refused and the refusal is audited as `auth.sso_refused`.
> `xms_edov` is `true` only when the tenant has verified the domain of the address;
> Microsoft states that Facebook and SAML/WS-Fed accounts do not have verified
> domains, so such users are refused as well.

References:
[Configure optional claims](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims),
[Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
(`xms_edov`, `email`),
[Group claims in tokens](https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles).

### Okta

1. Create an **OIDC web application** and register the redirect URI.
2. Add a groups claim to the ID token: on the app's **Sign On** tab, edit
   **OpenID Connect ID Token** and set a **Group claim type** with the claim name
   `groups` and a filter that returns the groups you map. On a custom
   authorization server, add a claim of value type **Groups** to the ID token
   instead.
3. With the org authorization server, request the `groups` scope:

```dotenv
QUERYPROXY_OIDC_ISSUER=https://example.okta.com
QUERYPROXY_OIDC_CLIENT_ID=<client-id>
QUERYPROXY_OIDC_CLIENT_SECRET=...
QUERYPROXY_OIDC_SCOPES="openid email profile groups"
QUERYPROXY_OIDC_ROLE_MAP=Platform DBA:payments:dba
```

With a custom authorization server the issuer is its URL, for example
`https://example.okta.com/oauth2/default`; there the groups come from the claim you
added, and `groups` is needed in `QUERYPROXY_OIDC_SCOPES` only if you tied that
claim to a `groups` scope. Reference:
[Customize tokens with a groups claim](https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/).

### Google

Create an OAuth client of type **Web application** in the Google Cloud console
and add the redirect URI. Google's issuer is `https://accounts.google.com`, and
its ID token carries `email_verified` when the `email` scope is requested.
Google's OpenID Connect documentation does not describe a groups claim, so use
Google for sign-in only and leave `QUERYPROXY_OIDC_ROLE_MAP` empty; give teams
to users by hand.

```dotenv
QUERYPROXY_OIDC_ISSUER=https://accounts.google.com
QUERYPROXY_OIDC_CLIENT_ID=<client-id>.apps.googleusercontent.com
QUERYPROXY_OIDC_CLIENT_SECRET=...
```

Reference: [OpenID Connect | Google Identity](https://developers.google.com/identity/openid-connect/openid-connect).

## Signing out

When a user who signed in through SSO signs out of QueryProxy, QueryProxy also
ends the session at the identity provider (RP-initiated logout), so the user is
signed out there too. It redirects the browser
to the provider's `end_session_endpoint` with the user's ID token as
`id_token_hint`, the `client_id` and the post-logout redirect URI
`{APP_URL}/login`.

- The provider must publish an `end_session_endpoint` in its discovery document.
  Without one, or when the discovery document cannot be read, sign-out ends only
  the QueryProxy session and returns to the login page.
- A session opened with a password, and a local user, never go to the provider.
- The ID token is kept in the server-side session for this purpose only. It is not
  written to the log or the audit log.
- Sign-out is a POST with a CSRF token, as before.

## Password login next to SSO

`QUERYPROXY_LOCAL_LOGIN` limits who may still use a password:

| Value | Meaning |
| :-- | :-- |
| `all` | Default. Everyone with a password can use it |
| `admins_only` | Only system administrators sign in with a password (break-glass access); everyone else uses SSO |
| `none` | Password login and password reset are off |

An unknown value is treated as `admins_only`. Make sure SSO works, with an
administrator account that can use it, before you set `none`: to get back in you
would have to change the variable and restart.

Accounts that exist only through SSO have no local password. A user linked to the
provider gets no password through the reset link, and the administrator's **Reset
password** action refuses them, whatever `QUERYPROXY_LOCAL_LOGIN` says. The
password form on the profile page is hidden for users without a local password.
Accounts created by [SCIM](/docs/scim/) count as SSO accounts.
`QUERYPROXY_REQUIRE_2FA` still applies to a local account that was linked to the
provider and signs in with its password.

A user who is linked to the provider **and** already had a local password can
still sign in with it while `QUERYPROXY_LOCAL_LOGIN` is `all`, and can change it
on the profile page. To make the provider the only way in for everyone, set
`QUERYPROXY_LOCAL_LOGIN=admins_only` or `none`.

## Limits

- **Sign-out at the provider needs an `end_session_endpoint`.** A provider that
  does not publish one keeps the user signed in there; see [Signing out](#signing-out).
- **One provider** per installation. SAML is not supported. Removing access
  between sign-ins is what [SCIM provisioning](/docs/scim/) is for.
- Creating a personal access token requires an SSO user without a password to have
  signed in within the last 15 minutes; see [REST API and tokens](/docs/api/).

## Next steps

- [SCIM provisioning](/docs/scim/) to create and suspend users from the provider.
- [Configuration](/docs/configuration/#single-sign-on) for the variables in context.
- [Production hardening](/docs/production-hardening/) for the rest of the checklist.
- [Approval workflow](/docs/approval-workflow/) for what each team role may do.
