Keyway SSO for Craft CMS › Guides

Craft CMS OpenID Connect login with Microsoft Entra ID

Azure AD is now Microsoft Entra ID, and Craft still has no OpenID Connect sign-in of its own. This page registers an application in your tenant, points it at the Craft control panel and explains the two places where OIDC behaves differently from SAML in this plugin.

Not verified against a live tenant. This guide was written from Microsoft's published documentation and from the measured behaviour of this plugin's own OIDC reader. Nobody has run it end to end against a real Entra ID tenant. Everything stated about this plugin is taken from its source code and is exact. Everything stated about the Microsoft portal — screen names, section names, button labels — is the part that may be wrong or out of date, and it is marked [portal] wherever it appears. The plugin's OIDC layer is exercised against Keycloak; if you want a provider this project has actually driven, start there.

What you will need

Step 1 — Copy the redirect URI this site answers on

In Craft, open Settings → Plugins → Keyway SSO and set Protocol to OpenID Connect. Do not fill in the provider fields yet: first copy the read-only Redirect URI this site answers on. It deliberately carries no control panel prefix, because that prefix can be renamed at any time and a redirect URI the provider no longer recognises stops every login.

Check the shape of that address before you register it. If it contains index.php?p=, this site generates URLs with the script name in them — turn on Craft's omitScriptNameInUrls and re-read the value. Some providers refuse a redirect URI containing a query string; whether Entra ID accepts such a URI is not verified, so register the clean form and avoid the question. Nothing is saved yet, and while the configuration is incomplete the SSO button stays off the login screen — that is normal.

Step 2 — Register the application

[portal] The path has changed several times; at the time of writing it is broadly Identity → Applications → App registrations → New registration, but navigate by what you are trying to achieve rather than by those names. What you need to end up with:

  1. An app registration restricted to this organizational directory only (single tenant), for the issuer reason above.
  2. A redirect URI of platform type "Web", set to exactly the address from Step 1 — character for character, including the scheme, any trailing element and letter case. Do not enable the implicit grant or hybrid options, and leave the response coming back as a redirect with a query string: this plugin uses the authorization code flow with PKCE and its callback reads query parameters only.
  3. A client secret, unless you deliberately want a public client. A secret makes it a confidential client; an empty Client secret field makes it a public one. PKCE (S256) is mandatory in both cases — it is a constant in the code, not a setting. [portal] Secrets live under the registration's Certificates & secrets; copy the value immediately, it is shown once.
  4. Delegated permissions matching the scopes you will request: at minimum openid, profile and email. This matters more than it looks — the default attribute mapping requires an email value, and an id token issued without the email scope (or for an account with no mail attribute) fails the login with attributes_rejected.
  5. Group claims, if you intend to map groups. [portal] They are configured on the registration's token configuration / optional claims. Read Step 4 first: the format you choose decides whether your mapping table holds readable names or GUIDs.

Know this symptom, because it is not what you would expect. A response POSTed back (response_mode=form_post) does not reach the plugin at all: CSRF validation is switched off only for the two SAML endpoints (the assertion consumer service and the logout receiver), never for the OIDC callback, so Craft rejects the POST before the plugin's callback action runs. The administrator gets an HTTP 400 "Unable to verify your data submission." and no diagnostics row is written — looking for one is a dead end.

What you do not need to configure: signing algorithms (the plugin accepts RS256 and ES256 only, and Entra's published id token signing algorithm is within that set), a PKCE method, or a logout URL — the OIDC side of this plugin has no logout support.

Step 3 — Paste the values back into Craft

Issuer. Do not type this from memory and do not copy it out of a blog post. Fetch your tenant's OpenID configuration document and copy the value of its issuer field verbatim:

curl -s https://login.microsoftonline.com/<your-tenant-id>/v2.0/.well-known/openid-configuration | grep -o '"issuer":"[^"]*"'

The plugin appends /.well-known/openid-configuration to whatever you enter, fetches that document, and refuses to go any further unless the document's own issuer equals your setting byte for byte — no trailing-slash forgiveness, no case folding. Run that same command from the web server, not only from your laptop: if the server cannot reach Microsoft, every login fails at start_failed / discovery_failed before the user ever sees Microsoft's page.

Client ID. The application (client) ID of the registration. The plugin requires it to appear in the id token's aud; when a token carries several audiences it additionally requires azp to name this client.

Client secret. Store it as an environment variable, not as a literal: put KEYWAY_OIDC_SECRET=… in the server's environment and write $KEYWAY_OIDC_SECRET in the field. If the variable is missing or empty, the settings screen says so by name and single sign-on stays off — it is not treated as "no secret", because that would silently downgrade a confidential client to a public one. Leave the field empty only if you genuinely registered a public client.

Redirect URI. The same characters you registered. The screen compares what you typed against what this site actually answers on and comments underneath if they reach the same endpoint but are written differently — take that comment seriously, both sides compare this value character for character.

Scopes. One per line; the shipped default is openid, profile, email. openid is added automatically whether you list it or not. Clearing the box restores the three defaults, and scopes containing a space are dropped silently.

Fetch userinfo is off by default. Turn it on only if a claim you need is missing from the id token: the userinfo sub must equal the id token's sub or the login is refused with subject_mismatch, id token claims win on conflict, and a signed userinfo response (application/jwt) is refused outright. Clock skew defaults to 60 seconds and caps at 120; an id token is refused once it is more than 300 seconds old whatever this is set to.

Step 4 — Attributes and groups

Do not leave the attribute table empty here. With no rows the plugin falls back to shipped defaults whose source names are email, firstName and lastName — and the standard OIDC claims Entra ID issues are given_name and family_name. firstName and lastName are not required, so the failure is quiet: the login succeeds, the diagnostics row is green, and accounts are created with an empty first and last name. The tell is Details → Mapped to → skippedSources, where a rule whose source never arrived is listed by name.

Fill the table in explicitly: emailemail with Required on, given_namefirstName, family_namelastName. Targets may be email, username, firstName, lastName, fullName or field:<handle>; nothing else is writable. Confirm the claim names against your own tenant rather than against this page — after the first attempt, the row's Details → Attributes received lists exactly what arrived.

Groups. Microsoft's group claim commonly carries group object IDs (GUIDs), not group names, and this plugin does not resolve them — it matches the strings it receives and nothing more. So your rules will contain patterns like 9a8b7c6d-1234-4321-abcd-0123456789ab, Prefix and Suffix matching is useless against them (use Exact), the table becomes unreadable to anyone who did not build it, and a wrong GUID in Admin rules grants Craft admin to the wrong people. [portal] Entra can be configured to emit group names instead of object IDs for groups synchronised from on-premises Active Directory; whether that applies to your directory is not something this guide can verify — check what actually arrives, under Details → Mapped to → matchedIdpGroups / unmatchedIdpGroups. More: group mapping.

How OIDC differs from SAML in this plugin

 SAML 2.0OpenID Connect
Address the provider needsACS URL (sso/acs), plus optional metadata documentRedirect URI (sso/callback); there is no metadata document
TransportHTTP-POST assertion; a query string in the address is finehttps required for both the issuer and the redirect URI, with no override; the response must come back on the query string
Returning user recognised byaccount, issuer and NameIDaccount, issuer and sub
LogoutIdP-initiated Single Logout — opt-in, one direction, and not with Okta (the caveats in full)No logout support in either direction
Crypto you can choosenone to choose: signed assertions required, requests sent unsignednone to choose: PKCE S256 always, RS256 / ES256 id tokens only

Testing, and the four codes you are most likely to meet

Test in a private window, leaving your administrator session signed in elsewhere. Then look at the diagnostics row even after a success: jit_create means a new Craft account was created, update_on_login that an existing one matched, existing_unchanged that nothing was changed. Watch for a blue Notice row too — provisioning_incomplete means the login worked but part of the configuration could not be applied (a Craft group handle that does not exist, a custom field missing from the user field layout), and nothing else in Craft will ever tell you that.

CodeUsual cause on a first attempt
start_failed (with discovery_failed or issuer_mismatch)The Issuer value is not exactly what the discovery document reports, or the server cannot reach Microsoft. Re-run the command from Step 3 on the server.
state_missingThe callback arrived without our state parameter — usually a redirect URI that does not point at this plugin's endpoint. A POSTed response produces no row at all (Step 2).
identity_rejected with audience_mismatchThe Client ID in Craft is not the one in the token's aud.
attributes_rejectedNo usable email claim arrived. Check the email scope and the account's mail attribute.

The person signing in sees one deliberately vague sentence whatever the cause; the real reason is always on the diagnostics screen.

Known limits with Entra ID

Keyway SSO for Craft CMS 5

SAML 2.0 and OpenID Connect sign-in for the Craft control panel: attribute and group mapping, just-in-time accounts, an admin password fallback with a guard that switches admin password login back on, with a warning, if your settings would otherwise leave nobody able to sign in, and a diagnostics screen that tells you why a login was refused. The plugin is in final testing and is on its way to the Craft Plugin Store.

Read the deployment documentation