Keyway SSO for Craft CMS › Guides
Almost every single sign-on failure has an exact machine reason behind it. The work is getting that reason on screen, and then not fixing the wrong side of the connection.
Keyway SSO records every attempt. The screen is at Settings → Plugins → Keyway SSO → Open sign-in diagnostics — the plugin registers no navigation item, so that button is the way in — and it is open to Craft admins only, because the rows carry other people's masked identifiers and group names. It keeps working while allowAdminChanges is off, which is where a failing login usually has to be diagnosed.
Read a failed row in three parts, in this order:
Error means the request or the configuration was wrong. Refused means everything verified and a policy said no — the fix is in the plugin's provisioning and group settings, not at the provider. Notice means the login worked and something about it deserves attention.Protocol, Login state, Attributes, Provisioning, Session.Rows live 30 days or 2 000 rows, whichever runs out first, and neither is configurable, so export or screenshot anything you need to keep. If the screen says the entries could not be read, the plugin's migration has not run: php craft up.
Everybody refused sees the same line on a 403 page: "We could not sign you in with single sign-on. If you believe this is a mistake, ask the person who runs this site to check the single sign-on diagnostics."
That is a design decision, not an unfinished error message. The value used to find the account comes from the identity provider, so a message that changed with the cause would let anyone who can reach the login endpoint tell "no such account" from "that account exists", from "that account is suspended", from "that account is an administrator". The split is deliberate: the visitor is told nothing, the administrator is told everything. When a user reports "it just says it could not sign me in", that is the product working — open diagnostics and read the newest row.
| Symptom | Reason code | What to check |
|---|---|---|
| The SSO button is missing and nothing is recorded | not_configured | The connection is selected but not usable, so no row can be attributed to it. The settings screen is the diagnosis: it says "Single sign-on is selected but the connection is not usable yet…" and marks the failing fields. |
| SAML fails with something that reads like a certificate problem | confirmation_invalid | By far the most common cause is that the assertion's SubjectConfirmationData/@Recipient is not literally equal to the configured ACS URL — no substring leniency and no way to relax it. Put three addresses side by side character for character: the ACS URL registered at the provider, the plugin's ACS URL field, and the read-only ACS URL this site answers on above it. A trailing slash, http against https, a differing host or a control-panel prefix is enough. |
| ACS URL mismatch at the endpoint itself | destination_mismatch | The assertion's Destination is not this endpoint: the address registered at the provider is not the one this site answers on. Copy it from the settings screen instead of typing it. |
| Invalid signature | signature_invalid | The signature does not verify against the configured IdP certificate, or a JWT signature failed. Re-copy the IdP signing certificate and check whether the provider rotated its key. A fingerprint is not accepted in place of the certificate — it proves which certificate the message carried, not which one you trust. |
| Signature is there but does not cover what was read | signature_coverage | SAML only: a missing or duplicate assertion ID, no single ds:Signature child, a Reference pointing elsewhere, or no enveloped-signature transform. A provider or proxy is re-wrapping the assertion. Do not relax anything — find what modifies the response. |
| Logins fail for some people and not others | assertion_expired | Clock drift. Compare the clocks on both machines before touching anything else. Skew tolerance is configurable up to 120 seconds; the time checks themselves cannot be switched off, an id token is refused once it is older than 300 seconds whatever the skew, and a login state expires after 5 minutes. |
The provider refuses a redirect URI with index.php?p= in it | — | On a site with Craft's default omitScriptNameInUrls = false, the plugin's addresses come out as <BASE>/index.php?p=actions/keyway-sso/sso/callback. That routes correctly and is fine for SAML; for OpenID Connect several providers refuse a redirect URI carrying a query string, so turn omitScriptNameInUrls on first. |
| It worked, then the control-panel URL was renamed and it stopped | varies | The provider was given a control-panel alias (<cp>/sso/acs), which contains this site's cpTrigger. Give the identity provider the action URL (<BASE>/actions/keyway-sso/sso/acs or …/sso/callback) and nothing else; the alias is for people, not for providers. |
| Wrong issuer or audience | issuer_mismatch, audience_mismatch | Both are compared byte for byte. Copy the issuer exactly as the provider publishes it, trailing slash included, and make the SP entity ID (or client ID) identical on both sides. |
| "Unsolicited" response, or a provider tile that goes nowhere | unsolicited_response, state_missing | Provider-initiated sign-on is not supported: this plugin only accepts logins it started. Send people to the Craft login screen and let them press the SSO button. |
| Every login is refused at the login-state check | state_rejected | The specific cause is appended in round brackets: (expired) means the login took longer than five minutes; (unknown) means it started on another web node or the cache was cleared mid-login, so check that all nodes share one cache; (already_used) is a replay or a back-button re-submit; (storage_unconfirmed) means the cache backing SSO state is not storing what the site writes to it, and that one breaks every login. |
| The response came back without its browser cookie | binding_cookie_missing | Either the response arrived in a different browser, which is what the check exists to stop, or something strips cookies from the callback — a reverse proxy, a privacy extension, a SameSite policy. A SAML callback is a cross-site POST and needs a SameSite=None cookie, which browsers accept only over HTTPS. |
| A blue notice says the login ran unbound | binding_not_issued | Not a refusal. No browser-binding cookie could be issued because the callback is a cross-site POST on a site not served over HTTPS. The login works meanwhile, with the login-CSRF gap open; serve the callback over HTTPS. |
| The mapping refused the response | attributes_rejected | A required attribute was not sent, or a value mapped to email is not a valid address. Open Details → Attributes received: it lists the claim names the provider actually sent. With the mapping table left empty the shipped defaults apply, and email is required. |
| Nobody can sign in on a site full of existing accounts | linking_disabled, jit_disabled | Both are fail-closed defaults, not faults. See the group mapping page for the two linking switches and the allowed-domain list. |
| One person is suddenly refused and nobody else | linking_disabled | The provider changed the subject (NameID / sub) it sends for them — a recreated directory account, a migrated profile, or a transient NameID format, which is a fresh value on every login by definition. Fix the format at the provider first (persistent or emailAddress), then clear the stale row. Do not reach for Link to existing Craft accounts as the first move: it opens every account on the site. |
| Second login of an account SSO created is refused | linking_disabled, with a notice row carrying identity_link_unavailable | The refusal itself is recorded as linking_disabled; the notice beside it is the one worth reading before blaming the provider, because the record of which accounts single sign-on created could not be read, so the login was decided as if the account had come from somewhere else. Run php craft up — the table arrived in a plugin update. |
| Everything verified and the person still cannot get in | no_group_match, no_cp_access, auth_refused | Policy, not protocol. Either no mapping rule matched while Deny login when no group matches is on, or the mapped groups grant no control panel permission, or Craft itself refused the account for its own state. The first two are on the group mapping page; the third is fixed on the account. |
| Craft refuses to save the new user | user_not_saved | The validation errors are in the message: usually a custom field rule, a duplicate username, or a required field the mapping does not fill. If it says no validation errors were reported, the edition's user limit is the likely cause — Craft refuses a new user outright when the edition is full and reports nothing further. |
[redacted]. Any attribute whose name contains password, secret, token, code and similar is masked when the row is written, and the match is a case-insensitive substring — so postalCode and countryCode are caught too. That is deliberate over-masking.If the failure is that people cannot use a password any more, that is a different screen and a different guard — see disabling password login safely.
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.