diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 478ae78e8308477bdb7a4e583b66fe5717f3c079..dfaeab737e5630ba79d2185cd60b118ae35a546e 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -271,6 +271,8 @@
   - 1
 - - compliance_management_standards_soc2_at_least_one_non_author_approval_group
   - 1
+- - compliance_management_timeout_pending_external_controls
+  - 1
 - - compliance_management_update_default_framework
   - 1
 - - compliance_management_violation_export_mailer
diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md
index 5c9cca7515ebb3406a2e435aa6af349fc2216b27..6edb717fe6b84f31c1f2490bc4c073dbfe08384d 100644
--- a/doc/user/compliance/audit_event_types.md
+++ b/doc/user/compliance/audit_event_types.md
@@ -170,6 +170,7 @@ Audit event types belong to the following product categories.
 | [`merge_request_merged`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/164846) | A merge request is merged | **{check-circle}** Yes | GitLab [17.5](https://gitlab.com/gitlab-org/gitlab/-/issues/442279) | Project |
 | [`omniauth_login_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123080) | An OmniAuth login fails | **{check-circle}** Yes | GitLab [16.3](https://gitlab.com/gitlab-org/gitlab/-/issues/374107) | User |
 | [`password_reset_requested`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114548) | A user requests a password reset using a registered email address | **{check-circle}** Yes | GitLab [15.11](https://gitlab.com/gitlab-org/gitlab/-/issues/374107) | User |
+| [`pending_compliance_external_control_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180016) | A project's compliance external control status is updated to fail because of timeout. | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project |
 | [`personal_access_token_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108952) | A user creates a personal access token | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374113) | User |
 | [`personal_access_token_revoked`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108952) | A personal access token is revoked | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374113) | User |
 | [`project_archived`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/117528) | A project is archived | **{check-circle}** Yes | GitLab [15.11](https://gitlab.com/gitlab-org/gitlab/-/issues/374105) | Project |
diff --git a/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb
index 49946bd59c73e1899cdbdbef91d2a4ec5b3ec28b..23cbca83fdd97df0298a99d8c0d71d201d639a25 100644
--- a/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb
+++ b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb
@@ -69,6 +69,9 @@ def handle_response(response)
           if response.success?
             audit_success(response.code)
 
+            ComplianceManagement::TimeoutPendingExternalControlsWorker.perform_in(31.minutes,
+              { 'control_id' => control.id, 'project_id' => project.id })
+
             ServiceResponse.success(payload: { control: control })
           else
             audit_error(Rack::Utils::HTTP_STATUS_CODES[response.code], response.code)
diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml
index b06705bb85565c5cbe991ac52243c4be748cfe0a..55428d16a55a46305b0ee0a71736f2c36a30b675 100644
--- a/ee/app/workers/all_queues.yml
+++ b/ee/app/workers/all_queues.yml
@@ -1693,6 +1693,16 @@
   :idempotent: true
   :tags: []
   :queue_namespace:
+- :name: compliance_management_timeout_pending_external_controls
+  :worker_name: ComplianceManagement::TimeoutPendingExternalControlsWorker
+  :feature_category: :compliance_management
+  :has_external_dependencies: false
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: true
+  :tags: []
+  :queue_namespace:
 - :name: compliance_management_update_default_framework
   :worker_name: ComplianceManagement::UpdateDefaultFrameworkWorker
   :feature_category: :compliance_management
diff --git a/ee/app/workers/compliance_management/timeout_pending_external_controls_worker.rb b/ee/app/workers/compliance_management/timeout_pending_external_controls_worker.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cab206845c7414abecf6c4190ae62d7f0e07cd59
--- /dev/null
+++ b/ee/app/workers/compliance_management/timeout_pending_external_controls_worker.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module ComplianceManagement
+  class TimeoutPendingExternalControlsWorker
+    include ApplicationWorker
+
+    idempotent!
+    feature_category :compliance_management
+    data_consistency :sticky
+    urgency :low
+
+    PENDING_STATUS_TIMEOUT = 30.minutes
+
+    def perform(args = {})
+      @args = args.with_indifferent_access
+
+      return unless valid_control?
+      return unless valid_project_control_compliance_status?
+      return unless timeout_project_control_compliance_status?
+
+      @project_control_compliance_status.fail!
+
+      create_audit_log
+    end
+
+    private
+
+    def valid_control?
+      control_id = @args[:control_id]
+      @control = ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.find_by_id(control_id)
+      @control.present?
+    end
+
+    def valid_project_control_compliance_status?
+      control_id, project_id = @args.values_at(:control_id, :project_id)
+      @project_control_compliance_status = ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus
+                                             .for_project_and_control(project_id, control_id).last
+
+      @project_control_compliance_status.present?
+    end
+
+    def timeout_project_control_compliance_status?
+      @project_control_compliance_status.pending? &&
+        @project_control_compliance_status.updated_at < PENDING_STATUS_TIMEOUT.ago
+    end
+
+    def create_audit_log
+      audit_context = {
+        name: 'pending_compliance_external_control_failed',
+        author: ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'),
+        scope: @project_control_compliance_status.project,
+        target: @project_control_compliance_status.project,
+        message: "Project control compliance status with URL #{@control.external_url} marked as fail."
+      }
+
+      ::Gitlab::Audit::Auditor.audit(audit_context)
+    end
+  end
+end
diff --git a/ee/config/audit_events/types/pending_compliance_external_control_failed.yml b/ee/config/audit_events/types/pending_compliance_external_control_failed.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5ef423c472102aa29433e8b3ea6240a687bad87a
--- /dev/null
+++ b/ee/config/audit_events/types/pending_compliance_external_control_failed.yml
@@ -0,0 +1,10 @@
+---
+name: pending_compliance_external_control_failed
+description: A project's compliance external control status is updated to fail because of timeout.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/513421
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180016
+feature_category: compliance_management
+milestone: '17.9'
+saved_to_database: true
+streamed: true
+scope: [Project]
diff --git a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb
index a31096e0f5c973974a8d9dfd64dc374e30ad1487..4cf4fc42295d67b43ea6a3047a4fcc078f4dfd9e 100644
--- a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb
+++ b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb
@@ -111,6 +111,13 @@
           end
         end
 
+        it 'schedules a timeout worker' do
+          expect(ComplianceManagement::TimeoutPendingExternalControlsWorker).to receive(:perform_in)
+            .with(31.minutes, { 'control_id' => control.id, 'project_id' => project.id })
+
+          service.execute
+        end
+
         it 'creates an audit event for successful request' do
           expect(Gitlab::Audit::Auditor).to receive(:audit).with(
             hash_including(
diff --git a/ee/spec/workers/compliance_management/timeout_pending_external_controls_worker_spec.rb b/ee/spec/workers/compliance_management/timeout_pending_external_controls_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9f05980beea2b8414ff8baca178ad71862f8f9bc
--- /dev/null
+++ b/ee/spec/workers/compliance_management/timeout_pending_external_controls_worker_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ComplianceManagement::TimeoutPendingExternalControlsWorker, feature_category: :compliance_management do
+  let_it_be(:project) { create(:project) }
+  let_it_be(:control) { create(:compliance_requirements_control, :external) }
+
+  let(:worker) { described_class.new }
+  let(:args) { { control_id: control.id, project_id: project.id } }
+
+  describe '#perform' do
+    context 'when control does not exist' do
+      let(:args) { { control_id: non_existing_record_id, project_id: project.id } }
+
+      it 'does nothing' do
+        expect(ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus)
+          .not_to receive(:for_project_and_control)
+
+        worker.perform(args)
+      end
+    end
+
+    context 'when compliance status does not exist' do
+      it 'does nothing' do
+        expect(::Gitlab::Audit::Auditor).not_to receive(:audit)
+
+        worker.perform(args)
+      end
+    end
+
+    context 'when compliance status exists' do
+      let_it_be(:compliance_status) do
+        create(:project_control_compliance_status,
+          project: project,
+          compliance_requirements_control: control,
+          status: :pending
+        )
+      end
+
+      context 'when status is not pending' do
+        before do
+          compliance_status.update!(status: :fail)
+        end
+
+        it 'does nothing' do
+          expect(compliance_status).not_to receive(:fail!)
+
+          worker.perform(args)
+        end
+      end
+
+      context 'when status was updated less than 30 minutes ago' do
+        before do
+          compliance_status.touch
+        end
+
+        it 'does nothing' do
+          expect(compliance_status).not_to receive(:fail!)
+
+          worker.perform(args)
+        end
+      end
+
+      context 'when status is pending and was updated more than 30 minutes ago' do
+        before do
+          compliance_status.update!(updated_at: 31.minutes.ago)
+        end
+
+        it 'marks status as failed' do
+          expect(compliance_status.reload.status).to eq("pending")
+
+          worker.perform(args)
+
+          expect(compliance_status.reload.status).to eq("fail")
+        end
+
+        it 'creates an audit event' do
+          expected_message = "Project control compliance status with URL #{control.external_url} marked as fail."
+
+          expect(::Gitlab::Audit::Auditor).to receive(:audit).with(
+            hash_including(
+              name: 'pending_compliance_external_control_failed',
+              author: instance_of(::Gitlab::Audit::UnauthenticatedAuthor),
+              scope: project,
+              target: project,
+              message: expected_message
+            )
+          )
+
+          worker.perform(args)
+        end
+      end
+    end
+
+    context 'when args are strings' do
+      let(:string_args) { { 'control_id' => control.id.to_s, 'project_id' => project.id.to_s } }
+
+      it 'handles string keys' do
+        expect(ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl)
+          .to receive(:find_by_id).with(control.id.to_s).and_return(control)
+
+        worker.perform(string_args)
+      end
+    end
+  end
+
+  it_behaves_like 'an idempotent worker' do
+    let(:job_args) { { control_id: control.id, project_id: project.id } }
+  end
+end