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

  1. Confirm the provider's discovery/JWKS URL is correct and reachable and that its keys include the kid in the failing token.
  2. Clear/refresh the cached JWKS keys (restart or expire the provider's cached keys) after an IdP key rotation.
  3. Ensure the token's signing algorithm (alg header) is one Canvas supports (typically RS256); configure the IdP to use RS256.
  4. Check TLS trust on the server fetching the JWKS (update CA bundle if self-signed/enterprise CA).
  5. 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

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


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

    protected

View on GitHub (pinned to 1c9f0bb801)