diff --git a/config/gitlab_loose_foreign_keys.yml b/config/gitlab_loose_foreign_keys.yml
index eb5bf8f775588b29ed3e902d25823746682ccbf7..16c64f5653593117c94d6ca38295d2eb87763b71 100644
--- a/config/gitlab_loose_foreign_keys.yml
+++ b/config/gitlab_loose_foreign_keys.yml
@@ -453,6 +453,10 @@ project_security_exclusions:
   - table: projects
     column: project_id
     on_delete: async_delete
+project_security_statistics:
+  - table: projects
+    column: project_id
+    on_delete: async_delete
 projects:
   - table: organizations
     column: organization_id
diff --git a/db/docs/project_security_statistics.yml b/db/docs/project_security_statistics.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1f86174a727a06df64416ab22ec09d33f161b55f
--- /dev/null
+++ b/db/docs/project_security_statistics.yml
@@ -0,0 +1,12 @@
+---
+table_name: project_security_statistics
+classes:
+- Security::ProjectStatistics
+feature_categories:
+- vulnerability_management
+description: Stores security-related statistics
+introduced_by_url:
+milestone: '17.5'
+gitlab_schema: gitlab_sec
+sharding_key:
+  project_id: projects
diff --git a/db/migrate/20240926161243_create_project_security_statistics_table.rb b/db/migrate/20240926161243_create_project_security_statistics_table.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b9ffedf9d4a3546b04d6dacd261e8f1fc9ec12d5
--- /dev/null
+++ b/db/migrate/20240926161243_create_project_security_statistics_table.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CreateProjectSecurityStatisticsTable < Gitlab::Database::Migration[2.2]
+  milestone '17.5'
+
+  def change
+    create_table :project_security_statistics, id: false do |t| # rubocop:disable Migration/EnsureFactoryForTable -- False positive
+      t.bigint :project_id, primary_key: true, default: nil
+      t.integer :vulnerability_count, default: 0, null: false
+    end
+  end
+end
diff --git a/db/schema_migrations/20240926161243 b/db/schema_migrations/20240926161243
new file mode 100644
index 0000000000000000000000000000000000000000..eee279010169ddb6ff118cd4e7b009b2628b3a20
--- /dev/null
+++ b/db/schema_migrations/20240926161243
@@ -0,0 +1 @@
+2baf9eab2929a27d4dfca9d7a811a44e39102e0ac642dc769a008a06c18940a9
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 838adf0408a3c5cdfad898584baf6f22d0f4b5ad..a6add27da228539d7c6d055aeda8e4152ceee1cf 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -16955,6 +16955,11 @@ CREATE SEQUENCE project_security_settings_project_id_seq
 
 ALTER SEQUENCE project_security_settings_project_id_seq OWNED BY project_security_settings.project_id;
 
+CREATE TABLE project_security_statistics (
+    project_id bigint NOT NULL,
+    vulnerability_count integer DEFAULT 0 NOT NULL
+);
+
 CREATE TABLE project_settings (
     project_id bigint NOT NULL,
     created_at timestamp with time zone NOT NULL,
@@ -25057,6 +25062,9 @@ ALTER TABLE ONLY project_security_exclusions
 ALTER TABLE ONLY project_security_settings
     ADD CONSTRAINT project_security_settings_pkey PRIMARY KEY (project_id);
 
+ALTER TABLE ONLY project_security_statistics
+    ADD CONSTRAINT project_security_statistics_pkey PRIMARY KEY (project_id);
+
 ALTER TABLE ONLY project_settings
     ADD CONSTRAINT project_settings_pkey PRIMARY KEY (project_id);
 
diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb
index 600cdb746f19b99d3d62c7d1632789e8c95875fb..6089305b97ae25df6a42fe9ed046badbf8dae112 100644
--- a/ee/app/models/ee/project.rb
+++ b/ee/app/models/ee/project.rb
@@ -77,6 +77,7 @@ def preload_protected_branches
       has_many :compliance_management_frameworks, through: :compliance_framework_settings, source: 'compliance_management_framework'
       has_one :security_setting, class_name: 'ProjectSecuritySetting'
       has_one :vulnerability_statistic, class_name: 'Vulnerabilities::Statistic'
+      has_one :security_statistics, class_name: 'Security::ProjectStatistics'
 
       has_one :dependency_proxy_packages_setting, class_name: '::DependencyProxy::Packages::Setting', inverse_of: :project
       has_one :zoekt_repository, class_name: '::Search::Zoekt::Repository', inverse_of: :project
@@ -463,6 +464,10 @@ def lock_for_confirmation!(id)
 
       with_replicator Geo::ProjectRepositoryReplicator
 
+      def security_statistics
+        super || (self.security_statistics = Security::ProjectStatistics.create_for(self))
+      end
+
       def pipeline_configuration_full_path
         compliance_framework_settings
           .order(:id)
diff --git a/ee/app/models/security/project_statistics.rb b/ee/app/models/security/project_statistics.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3660edacb3b510f73261fb8f05c1efe9f92c7577
--- /dev/null
+++ b/ee/app/models/security/project_statistics.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Security
+  class ProjectStatistics < Gitlab::Database::SecApplicationRecord
+    self.primary_key = :project_id
+    self.table_name = 'project_security_statistics'
+
+    belongs_to :project, optional: false
+
+    scope :by_projects, ->(project_ids) { where(project_id: project_ids) }
+
+    class << self
+      def create_for(project)
+        upsert({ project_id: project.id })
+
+        find_by_project_id(project.id)
+      end
+    end
+
+    def increase_vulnerability_counter!(increment)
+      self.class.by_projects(project_id).update_all("vulnerability_count = vulnerability_count + #{increment}")
+    end
+
+    def decrease_vulnerability_counter!(decrement)
+      self.class.by_projects(project_id).update_all("vulnerability_count = vulnerability_count - #{decrement}")
+    end
+  end
+end
diff --git a/ee/spec/factories/security/project_statistics.rb b/ee/spec/factories/security/project_statistics.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0b300c2b8a4ab4dcb90e74393551fb3bf5643bac
--- /dev/null
+++ b/ee/spec/factories/security/project_statistics.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :project_security_statistics, class: 'Security::ProjectStatistics' do
+    project
+  end
+end
diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb
index 6e61142bc937252ed24005952df737d8a6aa9645..ed13cd88af0f2d59c555a06eff5ff50b1f37da10 100644
--- a/ee/spec/models/ee/project_spec.rb
+++ b/ee/spec/models/ee/project_spec.rb
@@ -32,6 +32,7 @@
     it { is_expected.to have_many(:compliance_standards_adherence).class_name('Projects::ComplianceStandards::Adherence') }
     it { is_expected.to have_one(:security_setting).class_name('ProjectSecuritySetting') }
     it { is_expected.to have_one(:vulnerability_statistic).class_name('Vulnerabilities::Statistic') }
+    it { is_expected.to have_one(:security_statistics).class_name('Security::ProjectStatistics') }
     it { is_expected.to have_one(:security_orchestration_policy_configuration).class_name('Security::OrchestrationPolicyConfiguration').inverse_of(:project) }
     it { is_expected.to have_one(:dependency_proxy_packages_setting).class_name('DependencyProxy::Packages::Setting').inverse_of(:project) }
 
@@ -4918,4 +4919,33 @@ def stub_default_url_options(host)
       end
     end
   end
+
+  describe '#security_statistics' do
+    let_it_be(:project) { create(:project) }
+
+    subject(:security_statistics) { project.security_statistics }
+
+    context 'when there is no `project_security_statistics` for the project' do
+      it 'returns a new persisted `Security::ProjectStatistics` instance' do
+        expect(security_statistics).to be_an_instance_of(Security::ProjectStatistics)
+                                   .and have_attributes(project_id: project.id)
+      end
+
+      it 'does not fire additional queries after the first access' do
+        security_statistics # warmup
+
+        queries = ActiveRecord::QueryRecorder.new { security_statistics }
+
+        expect(queries.count).to be_zero
+      end
+    end
+
+    context 'when there is already a `project_security_statistics` for the project' do
+      let_it_be(:statistics) { create(:project_security_statistics, project: project) }
+
+      it 'returns the existing record' do
+        expect(security_statistics).to eq(statistics)
+      end
+    end
+  end
 end
diff --git a/ee/spec/models/security/project_statistics_spec.rb b/ee/spec/models/security/project_statistics_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c256e1306695a7c187e10869e08752165dc1d2c4
--- /dev/null
+++ b/ee/spec/models/security/project_statistics_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Security::ProjectStatistics, feature_category: :vulnerability_management do
+  let_it_be(:statistics) { create(:project_security_statistics) }
+
+  it { is_expected.to belong_to(:project).required }
+
+  describe 'scopes' do
+    describe '.by_projects' do
+      subject { described_class.by_projects(statistics.project_id) }
+
+      before do
+        create(:project_security_statistics)
+      end
+
+      it { is_expected.to contain_exactly(statistics) }
+    end
+  end
+
+  describe '.create_for' do
+    let_it_be_with_refind(:project) { create(:project) }
+
+    subject(:create_statistics) { described_class.create_for(project) }
+
+    context 'when there is already a record for the given project' do
+      let_it_be(:existing_record) { create(:project_security_statistics, project: project) }
+
+      it { is_expected.to eq(existing_record) }
+
+      it 'does not try to create a new record' do
+        expect { create_statistics }.not_to change { described_class.count }
+      end
+    end
+
+    context 'when there is no record for the given project' do
+      it 'creates a new record' do
+        expect { create_statistics }.to change { described_class.count }.by(1)
+      end
+    end
+  end
+
+  describe '#increase_vulnerability_counter!' do
+    subject(:decrease_vulnerability_counter) { statistics.increase_vulnerability_counter!(1) }
+
+    it 'decreases the `vulnerability_count` attribute by given number' do
+      expect { decrease_vulnerability_counter }.to change { statistics.reload.vulnerability_count }.by(1)
+    end
+  end
+
+  describe '#decrease_vulnerability_counter!' do
+    subject(:decrease_vulnerability_counter) { statistics.decrease_vulnerability_counter!(1) }
+
+    it 'decreases the `vulnerability_count` attribute by given number' do
+      expect { decrease_vulnerability_counter }.to change { statistics.reload.vulnerability_count }.by(-1)
+    end
+  end
+end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index bb11e63a783a532b886257de70487d41e0672a66..22c1c0a8db10fd1fffae7a509816168606be7772 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -895,6 +895,7 @@ project:
 - observability_traces
 - observability_logs
 - security_exclusions
+- security_statistics
 award_emoji:
 - awardable
 - user