diff --git a/app/services/ci/pipeline_creation/find_ci_config_spec_service.rb b/app/services/ci/pipeline_creation/find_ci_config_spec_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7d5135ff60d6268ca01c8e3d8747d459097fbe07
--- /dev/null
+++ b/app/services/ci/pipeline_creation/find_ci_config_spec_service.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Ci
+  module PipelineCreation
+    class FindCiConfigSpecService
+      include Gitlab::Utils::StrongMemoize
+
+      # This service is used by the frontend to display inputs as an HTML form
+      # when creating a pipeline as a web request.
+      # For the reason we are defaulting `pipeline_source` to be `web`.
+      def initialize(current_user:, project:, ref:, pipeline_source: :web)
+        @current_user = current_user
+        @project = project
+        @ref = ref
+        @pipeline_source = pipeline_source
+      end
+
+      def execute
+        unless current_user.can?(:download_code, project)
+          return error_response('insufficient permissions to read inputs')
+        end
+
+        if !project.repository.branch_or_tag?(ref) || sha.blank?
+          return error_response('ref can only be an existing branch or tag')
+        end
+
+        return error_response('config not found') unless project_config.exists?
+
+        # Since CI Config path is configurable (local, other project, URL) we translate
+        # all supported config types into an `include: {...}` statement.
+        # The inputs we are looking for are not directly defined at this level of YAML
+        # but inside the included file.
+        if project_config.internal_include_prepended?
+          # We need to read the uninterpolated YAML of the included file.
+          yaml_content = ::Gitlab::Ci::Config::Yaml.load!(project_config.content)
+          yaml_result = yaml_result_of_internal_include(yaml_content)
+
+          return error_response('invalid YAML config') unless yaml_result&.valid?
+
+          success_response(yaml_result.spec)
+        else
+          # For now we do nothing. The unsupported case is `ProjectConfig::SecurityPolicyDefault`
+          # which is used when the project has no CI config explicitly defined but it's enforced
+          # by default using policies.
+          success_response({})
+        end
+      rescue ::Gitlab::Ci::Config::Yaml::LoadError => e
+        error_response("YAML load error: #{e.message}")
+      end
+
+      private
+
+      attr_reader :current_user, :project, :ref, :pipeline_source
+
+      def success_response(spec)
+        ServiceResponse.success(payload: { spec: spec })
+      end
+
+      def error_response(message)
+        ServiceResponse.error(message: message)
+      end
+
+      def project_config
+        ::Gitlab::Ci::ProjectConfig.new(project: project, ref: ref, sha: sha, pipeline_source: pipeline_source)
+      end
+      strong_memoize_attr :project_config
+
+      # TODO: temporary technical debt until https://gitlab.com/gitlab-org/gitlab/-/issues/520828
+      def yaml_result_of_internal_include(content)
+        locations = content[:include]
+        return if locations.blank?
+
+        files = ::Gitlab::Ci::Config::External::Mapper::Matcher.new(context).process(locations)
+
+        ::Gitlab::Ci::Config::External::Mapper::Verifier.new(context).skip_load_content!.process(files)
+
+        files.first&.load_uninterpolated_yaml
+      end
+
+      def context
+        ::Gitlab::Ci::Config::External::Context.new(
+          project: project,
+          sha: sha,
+          user: current_user)
+      end
+      strong_memoize_attr :context
+
+      def sha
+        project.commit(ref)&.sha
+      end
+      strong_memoize_attr :sha
+    end
+  end
+end
diff --git a/lib/gitlab/ci/config/external/file/base.rb b/lib/gitlab/ci/config/external/file/base.rb
index cc4db8495f971d14bc7790f3dc4ba88ae301a30e..dd5202d5b7723b742b2e375a383278b5fd037eb9 100644
--- a/lib/gitlab/ci/config/external/file/base.rb
+++ b/lib/gitlab/ci/config/external/file/base.rb
@@ -104,6 +104,10 @@ def load_and_validate_expanded_hash!
               validate_hash!
             end
 
+            def load_uninterpolated_yaml
+              ::Gitlab::Ci::Config::Yaml::Loader.new(content).load_uninterpolated_yaml
+            end
+
             protected
 
             def content_inputs
diff --git a/lib/gitlab/ci/config/external/mapper/verifier.rb b/lib/gitlab/ci/config/external/mapper/verifier.rb
index 2d8e571b75528c09430304ef1b1d89f26362e4c1..c9bd3333b463fd8ab5707fd664f780cfbd220a6f 100644
--- a/lib/gitlab/ci/config/external/mapper/verifier.rb
+++ b/lib/gitlab/ci/config/external/mapper/verifier.rb
@@ -9,6 +9,11 @@ module External
         class Mapper
           # Fetches file contents and verifies them
           class Verifier < Base
+            # TODO: remove with https://gitlab.com/gitlab-org/gitlab/-/issues/520828
+            def skip_load_content!
+              tap { @skip_load_content = true }
+            end
+
             private
 
             def process_without_instrumentation(files)
@@ -38,7 +43,8 @@ def process_without_instrumentation(files)
                 verify_execution_time!
 
                 file.validate_content! if file.valid?
-                file.load_and_validate_expanded_hash! if file.valid?
+
+                file.load_and_validate_expanded_hash! if file.valid? && !@skip_load_content
 
                 next unless file.valid?
 
diff --git a/spec/services/ci/pipeline_creation/find_ci_config_spec_service_spec.rb b/spec/services/ci/pipeline_creation/find_ci_config_spec_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8b04ea68f7bba6d6724b2dd794e9759815d2916e
--- /dev/null
+++ b/spec/services/ci/pipeline_creation/find_ci_config_spec_service_spec.rb
@@ -0,0 +1,191 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::PipelineCreation::FindCiConfigSpecService, feature_category: :pipeline_composition do
+  let(:project) { create(:project, :repository) }
+  let_it_be(:user) { create(:user) }
+
+  let(:ref) { 'master' }
+  let(:pipeline_source) { :web }
+
+  subject(:service) do
+    described_class.new(current_user: user, project: project, ref: ref, pipeline_source: pipeline_source)
+  end
+
+  describe '#execute' do
+    let(:config_yaml_without_inputs) do
+      <<~YAML
+      job:
+        script: echo hello world
+      YAML
+    end
+
+    let(:config_yaml) do
+      <<~YAML
+      spec:
+        inputs:
+          foo:
+            default: bar
+      ---
+      job:
+        script: echo hello world
+      YAML
+    end
+
+    let(:expected_spec) do
+      { inputs: { foo: { default: 'bar' } } }
+    end
+
+    shared_examples 'successful response without spec' do
+      let(:config_yaml) { config_yaml_without_inputs }
+
+      it 'returns success response without spec' do
+        result = service.execute
+
+        expect(result).to be_success
+        expect(result.payload).to eq(spec: {})
+      end
+    end
+
+    shared_examples 'successful response with spec' do
+      it 'returns success response with spec' do
+        result = service.execute
+
+        expect(result).to be_success
+        expect(result.payload).to eq(spec: expected_spec)
+      end
+    end
+
+    context 'when user does not have permission to read code' do
+      before do
+        project.add_guest(user)
+      end
+
+      it 'returns error response' do
+        result = service.execute
+
+        expect(result).to be_error
+        expect(result.message).to eq('insufficient permissions to read inputs')
+      end
+    end
+
+    context 'when user has permissions to read code' do
+      before do
+        project.add_developer(user)
+      end
+
+      context 'when ref does not exist' do
+        let(:ref) { 'non-existent-branch' }
+
+        it 'returns error response' do
+          result = service.execute
+
+          expect(result).to be_error
+          expect(result.message).to eq('ref can only be an existing branch or tag')
+        end
+      end
+
+      context 'when ref is a SHA' do
+        let(:ref) { project.commit('master')&.sha }
+
+        it 'returns error response' do
+          result = service.execute
+
+          expect(result).to be_error
+          expect(result.message).to eq('ref can only be an existing branch or tag')
+        end
+      end
+
+      context 'when config does not exist and AutoDevOps is disabled' do
+        before do
+          allow(project).to receive(:auto_devops_enabled?).and_return(false)
+        end
+
+        it 'returns error response' do
+          result = service.execute
+
+          expect(result).to be_error
+          expect(result.message).to eq('config not found')
+        end
+      end
+
+      context 'when config is expected in the project' do
+        before do
+          project.repository.create_file(
+            project.creator,
+            '.gitlab-ci.yml',
+            config_yaml,
+            message: 'Add CI',
+            branch_name: 'master')
+        end
+
+        it_behaves_like 'successful response with spec'
+        it_behaves_like 'successful response without spec'
+
+        context 'when an error occurs during yaml processing' do
+          let(:config_yaml) do
+            <<~YAML
+            a*
+            test: <<a
+            YAML
+          end
+
+          it 'returns error response' do
+            result = service.execute
+
+            expect(result).to be_error
+            expect(result.message).to eq('invalid YAML config')
+          end
+        end
+
+        context 'when an error occurs during yaml loading' do
+          it 'returns error response' do
+            allow(::Gitlab::Ci::Config::Yaml)
+              .to receive(:load!)
+              .and_raise(::Gitlab::Ci::Config::Yaml::LoadError)
+
+            result = service.execute
+
+            expect(result).to be_error
+            expect(result.message).to match(/YAML load error/)
+          end
+        end
+      end
+
+      context 'when config is expected on another project' do
+        let!(:another_project) { create(:project, :repository) }
+
+        before do
+          another_project.add_developer(user)
+          another_project.repository.create_file(
+            another_project.creator,
+            'config.yml',
+            config_yaml,
+            message: 'Add CI',
+            branch_name: 'master')
+
+          project.update!(ci_config_path: "config.yml@#{another_project.full_path}")
+        end
+
+        it_behaves_like 'successful response with spec'
+        it_behaves_like 'successful response without spec'
+      end
+
+      context 'when config exists without internal include' do
+        before do
+          allow_next_instance_of(Gitlab::Ci::ProjectConfig) do |config|
+            allow(config).to receive_messages(exists?: true, internal_include_prepended?: false)
+          end
+        end
+
+        it 'returns success response with empty spec' do
+          result = service.execute
+
+          expect(result).to be_success
+          expect(result.payload).to eq({ spec: {} })
+        end
+      end
+    end
+  end
+end