instructure/canvas-lms · error · OAuthValidationError

Failed to decode OpenID Connect id_token: #

Error message

Failed to decode OpenID Connect id_token: #{jwt_string.inspect}

What it means

During OpenID Connect login the provider decodes the returned id_token with verification skipped (to read claims). If the string cannot be decoded as a JWT at all (InvalidToken/TokenExpired even with skip_verification), it logs this warning and raises OAuthValidationError, aborting the login. This means the identity provider returned something that is not a usable JWT.

Solutions

  1. Inspect what the IdP actually returns at the token endpoint (log the raw response body) and fix the issuer configuration (token URL, client_id/secret)
  2. Confirm the provider is an OIDC-compliant issuer that returns a JWT id_token (openid scope, correct response_type)
  3. Check for captive portals/proxies rewriting responses to HTML
  4. Update/re-test the authentication provider config in Canvas after IdP-side changes

Example fix

// before
provider.id_token = response.parsed_response['id_token'] rescue nil # nil/HTML slips through
// after
token = response.parsed_response&.dig('id_token')
raise OAuthValidationError, 'no id_token from IdP' unless token.is_a?(String) && token.count('.') == 2
Defensive patterns

Strategy: try-catch

Validate before calling

def plausible_jwt?(s)
  s.is_a?(String) && s.count('.') == 2 && s.length > 32
end
# check response.parsed_response&.dig('id_token') before handing to decode_jwt

Type guard

def jwt_like?(token)
  token.is_a?(String) && token.split('.').length == 3
end

Try / catch

begin
  id_token = Canvas::Security.decode_jwt(jwt_string, [:skip_verification])
rescue Canvas::Security::InvalidToken, Canvas::Security::TokenExpired => e
  raise OAuthValidationError, "IdP returned unusable id_token: #{e.class}"
end

Prevention

When it happens

Trigger: The OIDC token endpoint returns an id_token that is blank, opaque, HTML (an error page), or otherwise not a JWT during the authorization-code exchange.

Common situations: Misconfigured issuer (token endpoint returns an error payload with 200); provider returning non-JWT opaque tokens; network proxies injecting HTML error pages; wrong client credentials causing an error response parsed as a token; provider outage.

Understand the failure class

Related errors


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

Appendix: source

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

      super.tap do |options|
        case token_endpoint_auth_method
        when "client_secret_basic"
          options[:auth_scheme] = :basic_auth
        when "client_secret_post"
          options[:auth_scheme] = :request_body
        end
      end
    end

    def unverified_id_token(token)
      jwt_string = token.options[:jwt_string] = token.params["id_token"] || token.token
      debug_set(:id_token, jwt_string) if instance_debugging
      id_token = {} if jwt_string.blank?

      id_token ||= begin
        ::Canvas::Security.decode_jwt(jwt_string, [:skip_verification])
      rescue ::Canvas::Security::InvalidToken, ::Canvas::Security::TokenExpired => e
        Rails.logger.warn("Failed to decode OpenID Connect id_token: #{jwt_string.inspect}")
        raise OAuthValidationError, e.message
      end

      debug_set(:header, id_token.header.to_json) if instance_debugging
      debug_set(:claims, id_token.to_json) if instance_debugging

      id_token
    end

    private

    def download_jwks(force: false)
      if jwks_uri.blank?
        self.jwks = nil
        return
      end
      return unless force || settings["jwks"].nil? || jwks_uri_changed?

View on GitHub (pinned to 1c9f0bb801)