instructure/canvas-lms · critical · OAuthValidationError
Invalid signature
Error message
Invalid signature: %{signature_error} What it means
Raised in claims() when validate_signature(id_token) returns an error, meaning the ID token's JWS signature could not be verified against the IdP's published keys (JWKS). Canvas rejects tokens it cannot cryptographically attribute to the configured issuer's keys.
Solutions
- Confirm the provider's discovery/JWKS URL is correct and reachable and that its keys include the kid in the failing token.
- Clear/refresh the cached JWKS keys (restart or expire the provider's cached keys) after an IdP key rotation.
- Ensure the token's signing algorithm (alg header) is one Canvas supports (typically RS256); configure the IdP to use RS256.
- Check TLS trust on the server fetching the JWKS (update CA bundle if self-signed/enterprise CA).
- Decode the token at jwt.io using the IdP's public key to confirm the signature manually before changing config.
Example fix
# before
settings = { "client_id" => "abc", "issuer" => "https://idp.example.com" } # no/discovery wrong
# after
settings = { "client_id" => "abc", "issuer" => "https://idp.example.com/realms/main",
"discovery_url" => "https://idp.example.com/realms/main/.well-known/openid-configuration" } Defensive patterns
Strategy: try-catch
Validate before calling
jwks = JSON.parse(Net::HTTP.get(URI(jwks_url)))
kid = JSON.parse(Base64.urlsafe_decode64(id_token.split(".")[0]))["kid"]
raise "kid #{kid} not in JWKS" unless jwks["keys"].any? { |k| k["kid"] == kid } Try / catch
begin
provider.claims(token)
rescue OAuthValidationError => e
Rails.logger.error("OIDC signature validation failed: #{e.message}")
head :unauthorized
end Prevention
- Point the provider at the correct discovery/JWKS URL
- Expire/refresh cached JWKS keys to survive IdP key rotation
- Require RS256 (or a supported alg) on the IdP
- Keep server CA bundle updated for JWKS fetches
When it happens
Trigger: claims() calls validate_signature during login; common causes inside it: the kid in the token is not in the fetched JWKS, the IdP rotated keys and Canvas cached old keys, the token is signed with an unsupported algorithm (e.g. HS256 vs RS256), or the JWKS endpoint URL is wrong/unreachable, and the returned error string is interpolated into the message.
Common situations: IdP key rotation while Canvas caches JWKS keys; admin configured the provider with the wrong jwks/discovery URL; IdP switched signing algorithm after an upgrade; self-signed/intermediate CA issues fetching the JWKS over HTTPS; token manually forged or from a test IdP.
Understand the failure class
Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.
Related errors
- Invalid JWT audience
- Invalid JWT issuer
- Invalid nonce claim in ID Token
- Missing claims
- Access token invalid - signature likely incorrect
AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15).
Data as JSON: /api/errors/934ef515341e78cb.
Report an issue: GitHub.
Appendix: source
Thrown at app/models/authentication_provider/open_id_connect.rb:299
unless Array(id_token["aud"]).include?(client_id)
raise OAuthValidationError, t("Invalid JWT audience: %{audience}", audience: id_token["aud"].inspect)
end
if self.class.validate_issuer?
if issuer.blank?
raise OAuthValidationError, t("No issuer configured for OpenID Connect provider")
end
unless issuer === id_token["iss"] # rubocop:disable Style/CaseEquality -- may be a string or a RegEx
raise OAuthValidationError, t("Invalid JWT issuer: %{issuer}", issuer: id_token["iss"])
end
end
unless id_token["nonce"] == token.options[:nonce]
raise OAuthValidationError, t("Invalid nonce claim in ID Token")
end
if (signature_error = validate_signature(id_token))
raise OAuthValidationError, t("Invalid signature: %{signature_error}", signature_error:)
end
# we have a userinfo endpoint, and we don't have everything we want,
# then request more
if userinfo_endpoint.present? && !(requested_claims - id_token.keys).empty?
userinfo = token.get(userinfo_endpoint).parsed
debug_set(:userinfo, userinfo.to_json) if instance_debugging
# but only use it if it's for the user we logged in as
# see http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
if userinfo["sub"] == id_token["sub"]
id_token.merge!(userinfo)
end
end
id_token
end
end
protectedView on GitHub (pinned to 1c9f0bb801)