Keyway SSO for Craft CMS › Guides
Last updated:
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.
Start here if you have a symptom and no code yet. If you already have a reason code from a diagnostics row, go straight to every reason code, stage by stage below — that table lists all of them. When the provider's response itself is refused, the Reason column reads identity_rejected and the code named here stands in round brackets at the end of the message under Details.
| 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. |
The table above starts from what you see. This one starts from the Reason column of a diagnostics row: every code the plugin can produce, in the order a login travels, with what actually happened and what to do about it. Each row has an anchor, so #code- plus the code — #code-identity_rejected — links straight to it.
| Code | What actually happened | What to do |
|---|---|---|
not_configured | Single sign-on is selected but the saved configuration cannot sign anybody in, so the flow refused to start. No diagnostics row is written for this — there is no connection to attribute it to. | Open the settings screen. It shows "Single sign-on is selected but the connection is not usable yet…" and the failing fields. The SSO button is hidden in this state too, so "the button disappeared" and this refusal have the same cause. |
connection_cannot_start | The connection can validate a response but cannot build a request. Not reachable in the shipped configuration (one connection, both halves built together). No diagnostics row. | Treat as a bug and report it with the plugin version. |
start_failed | Building the authentication request threw. For OIDC this is nearly always the discovery document: unreachable, not JSON, wrong issuer, non-https endpoint, or no S256 PKCE support. For SAML it is a DEFLATE failure. The provider's own message and its reason code are in the row's detail. | Read the appended code in the message: discovery_failed (the well-known URL is wrong or the provider is down), issuer_mismatch (the document's issuer is not byte-for-byte the configured issuer), algorithm_not_allowed (the provider signs id tokens only with algorithms this plugin refuses). Check the Issuer field on the settings screen first. |
state_context_lost | The request builder issued a login state without the connection handle or the browser-binding decision, so the callback could never verify it. | Internal fault, caught at the start so it points at the cause. Report it; no configuration change fixes it. |
Login state)| Code | What actually happened | What to do |
|---|---|---|
state_missing | The callback carried no login state field at all (RelayState for SAML, state for OIDC). | Usually a scanner, a bookmarked callback URL, or an IdP-initiated login: this plugin only accepts logins it started. Also check that the provider is configured to relay the state parameter back. |
state_ambiguous | State fields for more than one connection arrived at once, so which login this is cannot be known. | Fails closed rather than guessing. Check for a proxy or a rewrite adding parameters to the callback. |
state_rejected | The state itself was refused. The specific reason is appended in round brackets at the end of the message, exactly as (expired), (unknown), (malformed), (already_used), (secret_mismatch), (storage_unconfirmed) — searchable as written. | expired — the login took longer than the window (5 minutes); start again. unknown — started on another server, or the cache was cleared mid-login; check that all web nodes share one cache. already_used — the response was replayed, or the user pressed back and re-submitted. storage_unconfirmed — the cache backing SSO state is not storing what this site writes to it; fix the cache configuration, this one will break every login. malformed / secret_mismatch — the value came back altered. |
connection_missing | The state does not say which connection it belongs to, so there is no way to know whose certificate should validate the response. Refused rather than falling back to the currently configured connection. | Internal fault (the storage layer filtered the key, or the starter rebuilt the context). Report it. |
connection_unknown | The state was issued for a connection this site no longer has configured. | The protocol was switched while a login was in flight. Ask the user to sign in again. |
connection_mismatch | The state came back in the other protocol's callback field — e.g. a SAML state arriving in state. | Check which callback address the provider posts to: the SAML assertion must go to the ACS URL, the OIDC code to the redirect URI. |
binding_cookie_missing | A browser-binding cookie was issued and the browser sent none back. | Two possibilities with opposite fixes. Either the response arrived in a different browser (which is what the check exists to stop), or something between the browser and the site strips cookies from the callback — a reverse proxy, a privacy extension, or a SameSite policy. For SAML the callback is a cross-site POST and needs a SameSite=None cookie, which browsers only accept over HTTPS. |
binding_mismatch | A binding cookie arrived and it belongs to a different login. | A response was presented to a browser that did not start it. Ask the user to close other login tabs and try once; if it repeats for everybody, look for a cache in front of the site serving one user's cookie to another. |
binding_undeclared | The state carries no record of whether a binding was issued at all. Refused — a binding inferred from a missing value could be switched off by sending nothing. | Internal fault; report it. |
binding_inconsistent | The state says it was bound to a browser but carries no value to compare against. | Internal fault; report it. |
state_not_burnt | The response verified, but the login state was not marked as used, so it could be replayed. Refused after a successful verification. | Fail-closed guard against a programming error or a cache that accepts writes without storing them. Check the cache backend (the same cause as storage_unconfirmed), then report it. |
binding_not_issued | Notice, not a refusal. The login ran (or started) without the cookie that ties it to the browser that began it, because none could be issued: a cross-site POST callback on a site not served over HTTPS. | Serve the callback URL over HTTPS. The login works meanwhile, with the login-CSRF gap open. The settings screen carries the matching warning. |
Protocol)| Code | What actually happened | What to do |
|---|---|---|
identity_rejected | The protocol reader refused the response. The reader's own code is appended in round brackets at the end of the message, exactly as (issuer_mismatch) or (signature_invalid), and that code is the part that matters — see the table below. | Look up the appended code. |
Codes that appear inside identity_rejected — and, for the discovery-related ones, inside start_failed:
| Appended code | Meaning | Typical fix |
|---|---|---|
signature_invalid | The signature does not verify against the configured IdP certificate, or the JWT signature failed. | Re-copy the IdP signing certificate / check that the provider rotated its key. |
signature_coverage | SAML only: the signature does not cover the assertion that was parsed — 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; investigate what modifies the response. |
malformed_response | The document could not be parsed or is structurally wrong: no SAMLResponse field, bad base64, not well-formed XML, a token response that is not JSON, no id_token, an unusable access_token, a non-Bearer token type, an unreadable aud, a missing iat. For OpenID Connect this is also what a wrong client secret looks like: the provider's token endpoint answers with an error instead of tokens. | For SAML, read the message. For OpenID Connect, read the Detail: part of the message (since 1.0.2): Token endpoint answered HTTP 401: followed by unauthorized_client (Keycloak) or invalid_client (the code the OAuth 2.0 specification uses) is a wrong Client secret (check the environment variable behind it too); anything else, compare with the provider's own log. |
issuer_mismatch | The issuer in the response (or in the discovery document) is not the configured one, compared byte for byte. | Copy the issuer exactly as the provider publishes it, including or excluding a trailing slash. |
audience_mismatch | SAML: the AudienceRestriction does not name this SP entity ID. OIDC: aud does not contain the client ID, or there are several audiences and no azp naming this client. | Make the SP entity ID / client ID identical on both sides. |
assertion_expired | A time window did not check out: SAML NotBefore/NotOnOrAfter, or OIDC exp / iat in the future / id token older than the accepted login window (5 minutes) / nbf. | Check clock sync on both machines first. Clock skew tolerance is configurable up to 120 seconds; the checks themselves cannot be turned off. |
replayed_assertion | The same assertion or id token (jti) was presented twice. | Back button or an actual replay. Not a configuration problem. |
unsolicited_response | Nothing tied the response to a login this site started: no RelayState / state, a state the store rejected, a state carrying no nonce or PKCE verifier, or a SAML InResponseTo that does not match the request id this site issued. | IdP-initiated sign-on is not supported — users must start at the Craft login screen. |
subject_missing | No non-empty NameID / sub. | Configure the provider to release a subject. |
subject_mismatch | The userinfo response describes a different subject than the verified id token. | Turn off Fetch userinfo and report it; this is the shape of an account mix-up. |
multiple_assertions | SAML: more than one assertion element, or decrypted content that is not exactly one assertion. | Configure the provider to send exactly one. |
status_not_success | The provider itself said the login failed — SAML StatusCode other than Success, or an OIDC callback carrying error=…. The failure happened at the provider, and its value is in the message. | Read the provider's own log: consent denied, user not assigned to the application, MFA refused. |
confirmation_invalid | SAML, and the first cause is by far the most common: the assertion's SubjectConfirmationData/@Recipient is not literally equal to the configured ACS URL (no substring or prefix leniency, no way to relax it). The message quotes the Recipient that arrived. The same code also covers a missing bearer SubjectConfirmation, not exactly one SubjectConfirmationData, and a SubjectConfirmationData with no NotOnOrAfter of its own. | For the Recipient case: put the three addresses side by side character for character — the ACS URL registered at the provider, the value in the plugin's ACS URL field, and the read-only ACS URL this site answers on shown above it. A trailing slash, http against https, a control-panel prefix or a differing host is enough. For the other causes the provider's SAML profile is wrong: the assertion must use the bearer confirmation method with exactly one data element. |
destination_mismatch | SAML: the Destination attribute is not this endpoint. | The ACS URL registered at the provider is not the one this site answers on. Copy the address shown on the settings screen. |
doctype_rejected | The XML carried a DOCTYPE declaration. | Refused outright (entity-expansion defence). Whatever produced that document is not sending plain SAML. |
decryption_failed | An EncryptedAssertion arrived with no SP private key configured, or decryption failed. | Fill in SP private key (as an environment variable), or turn encryption off at the provider. Two limits apply: the plugin has no field for an SP certificate, so the matching certificate has to reach the provider out of band, and encrypted assertions are implemented but unverified — not covered by an automated test and not run against a live provider. |
algorithm_not_allowed | OIDC: the token header, the JWKS key, or the provider's advertised list offers only algorithms this plugin refuses. Only RS256 and ES256 are accepted, and the list is fixed in code. | Configure the provider to sign id tokens with RS256 or ES256. |
key_not_found | No JWKS key matched the token's kid even after a refresh. | The provider rotated keys faster than the refresh rule allows, or the JWKS URL is wrong. Retry once, then check discovery. |
nonce_mismatch | The id token carries no nonce, or a nonce belonging to a different login. | The provider is dropping the nonce parameter. This cannot be disabled here. |
token_hash_mismatch | at_hash or c_hash present and not matching the token / code it should. | Report it — this indicates a substituted token or code. |
discovery_failed | The discovery document could not be fetched, answered a non-2xx status, is not a JSON object, advertises a non-https endpoint, does not support S256, or the token / userinfo endpoint could not be reached. | Fetch <issuer>/.well-known/openid-configuration from the server itself (firewall, proxy, TLS trust store), then re-check the issuer value. |
Attributes)| Code | What actually happened | What to do |
|---|---|---|
attributes_rejected | The response verified, but mapping refused it: either a required attribute was not sent, or a value mapped to email is not a valid address. The message names the exact source attributes. | Open Details → Attributes received on that row: it shows the claim names the provider actually sent. Either add the claim at the provider, point the mapping rule at a claim that is there, or clear the Required switch on that rule. Remember that with the mapping table left empty, the shipped defaults apply and email is required. |
Provisioning, outcome Refused)These rows mean the response was completely valid; the plugin's own policy said no. The fix is on the Craft settings screen, not at the provider.
| Code | What actually happened | What to do |
|---|---|---|
domain_not_allowed | The address's domain is not on Allowed e-mail domains. The allowed list is in the message. | Add the domain, or leave the field empty to allow any. example.com matches exactly; .example.com matches sub-domains only. |
email_missing | No e-mail address was mapped, and it was needed — either because a domain list is configured, or because it is the match key, or because a new account cannot be created without one. | Fix the attribute mapping (see attributes_rejected). |
username_missing | Match existing accounts by is set to Username and no username was mapped. | Map a claim to username, or match by e-mail. |
no_group_match | Deny login when no group matches is on and none of the provider's groups matched a mapping rule. Note that the default group deliberately does not satisfy this check. | Open Details → Mapped to → unmatchedIdpGroups: it shows the group names that arrived, in full. Add a rule for one of them, or turn the switch off. |
jit_disabled | No Craft account matched and Create accounts on first login is off. | Turn it on, or create the account by hand first. |
linking_disabled | A Craft account already exists for this identity and Link to existing Craft accounts is off. Deliberately off by default — this is the boundary an account takeover crosses. Second cause, and the one that looks like a configuration mistake and is not one: the plugin's table of accounts it created itself cannot be read, so an account this connection made just-in-time looks like somebody else's. Third cause, for one person only: the account was created by SSO, but the provider is now sending a different subject (NameID / sub) for them, so the record no longer matches — see "one person suddenly refused" below. | Turn it on and fill in the allowed domains. It never falls through to "create a second account instead". If the person is signing in for the second time to an account SSO created, run the pending migrations first (php craft up) — see identity_link_unavailable below. If it is one person and only one person, read the subject block below before changing any setting. |
admin_link_not_allowed | The matching account is an administrator account and Linking may include admin accounts is off. | A second, separate opt-in on purpose. Turn it on only if you accept that whoever administers the directory can hand out the owner's account. |
account_suspended | The matching Craft account is suspended. Single sign-on does not lift a suspension. | Unsuspend it in the control panel. |
account_inactive | The matching account has been deactivated. SSO does not reactivate an account somebody deactivated on purpose. | Reactivate it in the control panel first. |
account_locked | The account is locked after repeated failed sign-in attempts. SSO does not clear a lockout. | Unlock it in the control panel. |
identity_link_unavailable | Notice, not a refusal, and the one to read before blaming the identity provider: the record of which accounts single sign-on created could not be read, so the login was decided as if the account had not been created here. The refusal itself is then reported as linking_disabled. The message says which of the two it is — the plugin's table is missing (the migrations have not run on this site) or could not be read, followed by the class of the error. The database's own text is deliberately not stored: it would carry the SQL statement into a screen support staff read. | Run the pending database updates (php craft up), then have them sign in again. If this appears on a site that has been running for a while, that is the expected cause: the table arrived in a plugin update and craft up had not been run since. If the table is there, look in Craft's log for the driver's message. |
Successful provisioning rows use jit_create (a new account was created), update_on_login (an existing account matched and mapped values were applied) and existing_unchanged (matched, sync on login is off, nothing changed). Seeing existing_unchanged when you expected fields to update means Update accounts on every login is off. A Provisioning row records the decision (outcome Accepted), written before the account is saved and the session starts. A login that worked has a Signed in row at stage Session directly above it. If an Error row at stage Session (for example no_cp_access or user_not_saved) stands there instead, both belong to the same attempt and the person was not signed in.
One person suddenly refused with linking_disabled, and nobody else. When single sign-on creates an account it records three things: the Craft account, the identity provider's issuer, and the subject (NameID in SAML, sub in OIDC) it was created for. The next login is recognised only when all three match — without the subject, anybody else in the same directory who can put this person's e-mail address in their own profile would be let into this person's Craft account. So when a provider changes the subject it sends for somebody — the directory account was deleted and recreated, the profile was migrated, or the SAML NameID format is transient, which is a new value on every login by definition — the record stops matching and that person is refused. Their Craft account is fine; the record of who owns it is stale. What to do, in order:
keyway_sso_links table for that account (SELECT * FROM keyway_sso_links WHERE userId = <the Craft user id>). Different value, same issuer = this case.persistent or emailAddress — not transient. Otherwise every login of every user hits this from now on.keyway_sso_links. Since a Craft account still matches, either link it deliberately (Link to existing Craft accounts, with Allowed e-mail domains filled in, and switch it back off afterwards if that is your posture) or let them sign in once that way and leave the rest of the site unchanged.Do not reach for Link to existing Craft accounts as the first move. It is the setting that opens every account on the site to whatever the directory sends, and the refusal you are looking at is one person with a changed identifier.
Session)Reached only after everything verified and the policy allowed the login.
| Code | What actually happened | What to do |
|---|---|---|
no_cp_access | The account was provisioned but has no control panel permission, so signing it in would drop somebody on a page they cannot use. The missing permission is named in the message. | A just-created account gets this when the group mapping put it in no group granting control panel access. Fix the group mapping or the Craft group's permissions. |
auth_refused | Craft itself refused to authenticate the account after provisioning — suspended, pending, locked, archived, or owed a password reset. This is the account's own state, not a permissions problem. | The exact Craft status is in the message; fix the account in the control panel. |
user_not_saved | Craft refused to save the user element; its validation errors are in the message. | Usually a custom field validation rule, a duplicate username or a required field the mapping does not fill. A blank username is not one of them: a created account falls back to the mapped e-mail address, so username: Username cannot be blank means the mapping carried neither. If the message says no validation errors were reported, the licence limit is the likely cause: Craft refuses a new user outright when the edition is full (Solo allows 1 user, Team 5), and it reports nothing further. |
account_vanished | The account this login matched no longer exists, or could not be loaded. | Deleted while the login was in flight. Ask the user to try again. |
session_not_started | Craft accepted the account but would not start a session for it. | Check session storage and cookie configuration. |
not_allowed | A denied decision reached the sign-in step. Defensive; not reachable in normal operation. | Report it. |
provisioning_incomplete | Notice, not a refusal. The login succeeded, but part of what was configured could not be applied — a mapped Craft group handle that does not exist on this site, a custom field that is not in the user's field layout, or group membership that could not be written. | The person is signed in with a permission set that is not the one on the settings screen, and nothing else would ever say so. The message names the group handle or field. Fix the handle, then have them sign in again. |
decision_missing | A login was allowed with no provisioning decision behind it and was refused. | Not reachable through configuration; report it. |
[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. Keyway SSO is available in the Craft Plugin Store.