instructure/canvas-lms · error · OAuthValidationError

Invalid JWT issuer

Error message

Invalid JWT issuer: %{issuer}

What it means

Raised in claims() when validate_issuer? is enabled and the ID token's iss claim does not match the configured issuer. Canvas uses case equality (===) so the configured issuer may be a String or RegEx; any mismatch means the token was issued by an unexpected authority and is rejected.

Solutions

  1. Copy the issuer value exactly from the IdP's discovery document (.well-known/openid-configuration 'issuer' field) into the provider settings.
  2. Compare the iss value in the error message to the configured issuer and fix scheme/host/port/path/trailing-slash differences.
  3. Use a RegEx issuer in Canvas settings if the IdP serves multiple valid issuer URLs.
  4. Update the config after IdP migrations that change the issuer URL.

Example fix

# before
issuer: "https://idp.example.com"
# after (match discovery-document issuer exactly)
issuer: "https://idp.example.com/realms/main"
Defensive patterns

Strategy: validation

Validate before calling

discovery = JSON.parse(Net::HTTP.get(URI("#{idp_base}/.well-known/openid-configuration")))
payload = JSON.parse(Base64.urlsafe_decode64(id_token.split(".")[1]))
raise "issuer mismatch" unless discovery["issuer"] == payload["iss"]

Try / catch

begin
  provider.claims(token)
rescue OAuthValidationError => e
  Rails.logger.error("OIDC issuer mismatch: #{e.message}")
  redirect_to login_path, alert: "Login failed: unknown identity provider."
end

Prevention

When it happens

Trigger: claims() runs during login with a configured issuer; issuer === id_token['iss'] is false — e.g. configured 'https://idp.example.com' but token says 'https://idp.example.com/realms/main' or uses a different host/port/scheme, or internal vs external URL differs behind a proxy.

Common situations: IdP issuer URL includes a realm/path or trailing difference not reflected in the Canvas config; load balancer terminates TLS so token iss has http vs https mismatch; IdP upgraded and changed its issuer URL (e.g. moved to a new realm path); admin copied the well-known endpoint URL instead of the issuer value.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/5bede514c7b3bdae. Report an issue: GitHub.

Appendix: source

Thrown at app/models/authentication_provider/open_id_connect.rb:291

      token.options[:claims] ||= begin
        id_token = unverified_id_token(token)

        unless (missing_claims = %w[aud iss iat exp nonce] - id_token.keys).empty?
          raise OAuthValidationError, t({ one: "Missing claim %{claims}", other: "Missing claims %{claims}" },
                                        count: missing_claims.length,
                                        claims: missing_claims.join(", "))
        end

        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"]

View on GitHub (pinned to 1c9f0bb801)