Keyway SSO for Craft CMS › Guides
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.
https, with no loopback exception and no setting to relax it.issuer field and against the
iss claim of every id token — so a multi-tenant endpoint whose discovery document
returns a templated issuer (one containing a {tenantid}-style placeholder)
can never match.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.
[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:
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.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.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.
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.
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: email → email with
Required on, given_name → firstName,
family_name → lastName. 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.
| SAML 2.0 | OpenID Connect | |
|---|---|---|
| Address the provider needs | ACS URL (sso/acs), plus optional metadata document | Redirect URI (sso/callback); there is no metadata document |
| Transport | HTTP-POST assertion; a query string in the address is fine | https required for both the issuer and the redirect URI, with no override; the response must come back on the query string |
| Returning user recognised by | account, issuer and NameID | account, issuer and sub |
| Logout | IdP-initiated Single Logout — opt-in, one direction, and not with Okta (the caveats in full) | No logout support in either direction |
| Crypto you can choose | none to choose: signed assertions required, requests sent unsigned | none to choose: PKCE S256 always, RS256 / ES256 id tokens only |
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.
| Code | Usual 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_missing | The 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_mismatch | The Client ID in Craft is not the one in the token's aud. |
attributes_rejected | No 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.
sub is a pairwise identifier: stable for a given user
in a given app registration, and a different value in a different one. Moving this site to a new
registration, or recreating it, changes sub for everybody, and every account single
sign-on created is then refused with linking_disabled until an administrator clears
the stale records. It is not a reason to avoid doing it; it is a reason to plan it.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.