diff --git a/ee/app/models/cloud_connector/keys.rb b/ee/app/models/cloud_connector/keys.rb
index d13f3be50b58e08b452d448c18306d6d67b0a0b6..e9de0b987c146475c9f03dd28917c9e92f5ede91 100644
--- a/ee/app/models/cloud_connector/keys.rb
+++ b/ee/app/models/cloud_connector/keys.rb
@@ -8,21 +8,59 @@ class Keys < ApplicationRecord
     validates :secret_key, rsa_key: true, allow_nil: true
 
     scope :valid, -> { where.not(secret_key: nil) }
+    scope :ordered_by_date, -> { valid.order(created_at: :asc) }
 
     class << self
       def current
-        valid.order(created_at: :asc).first
+        ordered_by_date.first
       end
 
       def current_as_jwk
-        current&.secret_key&.then do |key_data|
-          ::JWT::JWK.new(OpenSSL::PKey::RSA.new(key_data), kid_generator: ::JWT::JWK::Thumbprint)
-        end
+        current&.secret_key&.then { |keydata| pem_to_jwk(keydata) }
       end
 
       def all_as_pem
         valid.map(&:secret_key)
       end
+
+      def create_new_key!
+        create!(secret_key: new_private_key.to_pem)
+      end
+
+      def rotate!
+        keys = ordered_by_date
+        key_count = keys.count
+
+        raise "Key rotation requires exactly 2 keys, found #{key_count}" if key_count != 2
+
+        current_key, next_key = *keys
+
+        transaction do
+          current_key_data = current_key.secret_key
+          current_key.update!(secret_key: next_key.secret_key)
+          next_key.update!(secret_key: current_key_data)
+        end
+      end
+
+      def trim!
+        raise 'Refusing to remove single key, as it is in use' if valid.count == 1
+
+        ordered_by_date.last&.destroy
+      end
+
+      private
+
+      def new_private_key
+        OpenSSL::PKey::RSA.new(2048)
+      end
+
+      def pem_to_jwk(key_data)
+        ::JWT::JWK.new(OpenSSL::PKey::RSA.new(key_data), kid_generator: ::JWT::JWK::Thumbprint)
+      end
+    end
+
+    def truncated_pem
+      secret_key&.truncate(90)
     end
   end
 end
diff --git a/ee/lib/tasks/cloud_connector/keys.rake b/ee/lib/tasks/cloud_connector/keys.rake
new file mode 100644
index 0000000000000000000000000000000000000000..265a47cd97aee5b87ff4e7cf8e25303f1953fa88
--- /dev/null
+++ b/ee/lib/tasks/cloud_connector/keys.rake
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+namespace :cloud_connector do
+  namespace :keys do
+    desc 'GitLab | Cloud Connector | List private keys in PEM format'
+    task :list, [:truncate] => :environment do |_, args|
+      truncate_keys = Gitlab::Utils.to_boolean(args[:truncate], default: true)
+      keys = truncate_keys ? CloudConnector::Keys.valid.map(&:truncated_pem) : CloudConnector::Keys.all_as_pem
+
+      puts <<~TXT.strip
+      ================================================================================
+      #{keys.any? ? keys.join("\n") : 'No keys found.'}
+      ================================================================================
+      TXT
+    rescue StandardError => e
+      exit_with_message(e.message)
+    end
+
+    desc 'GitLab | Cloud Connector | Create and store a new private key'
+    task create: :environment do
+      key = CloudConnector::Keys.create_new_key!
+
+      puts <<~TXT.strip
+      ================================================================================
+      Key created: #{key.truncated_pem}
+
+      This key will now be published via /oauth/discovery/keys.
+
+      If an older key existed prior to creation, run
+      `bundle exec rake cloud_connector:keys:rotate` to use it for signing tokens.
+      ================================================================================
+      TXT
+    rescue StandardError => e
+      exit_with_message(e.message)
+    end
+
+    desc 'GitLab | Cloud Connector | Performs key rotation by swapping two keys'
+    task rotate: :environment do
+      CloudConnector::Keys.rotate!
+
+      puts <<~TXT.strip
+      ================================================================================
+      Keys swapped successfully.
+
+      Run this task again to revert to using the previous key.
+
+      If the key that is now used to sign tokens should remain in use, run
+      `bundle exec rake cloud_connector:keys:trim` to remove the old key.
+      ================================================================================
+      TXT
+    rescue StandardError => e
+      exit_with_message(e.message)
+    end
+
+    desc 'GitLab | Cloud Connector | Removes the newest key'
+    task trim: :environment do
+      key = CloudConnector::Keys.trim!
+
+      puts <<~TXT.strip
+      ================================================================================
+      Key removed: #{key.truncated_pem}
+      ================================================================================
+      TXT
+    rescue StandardError => e
+      exit_with_message(e.message)
+    end
+
+    private
+
+    def exit_with_message(message)
+      puts <<~TXT.strip
+      ================================================================================
+      ERROR: #{message}
+      ================================================================================
+      TXT
+
+      exit(1)
+    end
+  end
+end
diff --git a/ee/spec/models/cloud_connector/keys_spec.rb b/ee/spec/models/cloud_connector/keys_spec.rb
index f5b025e9d00b905b86b507483a74d0660eba3bd6..bbe49017a81494a33d4587976f8b7d288bb201f2 100644
--- a/ee/spec/models/cloud_connector/keys_spec.rb
+++ b/ee/spec/models/cloud_connector/keys_spec.rb
@@ -44,13 +44,22 @@
   end
 
   describe '.valid' do
-    subject(:jwks) { described_class.valid }
+    subject(:keys) { described_class.valid }
 
     include_examples 'serving valid keys'
   end
 
+  describe '.ordered_by_date' do
+    subject(:keys) { described_class.ordered_by_date }
+
+    let_it_be(:first_key) { create(:cloud_connector_keys, created_at: Time.current - 1.minute) }
+    let_it_be(:second_key) { create(:cloud_connector_keys) }
+
+    it { is_expected.to eq([first_key, second_key]) }
+  end
+
   describe '.all_as_pem' do
-    subject(:jwks) { described_class.all_as_pem }
+    subject(:keys) { described_class.all_as_pem }
 
     include_examples 'serving valid keys'
 
@@ -132,4 +141,70 @@
       end
     end
   end
+
+  describe '.create_new_key!' do
+    it 'creates a new valid key' do
+      expect { described_class.create_new_key! }.to change { described_class.valid.count }.from(0).to(1)
+    end
+  end
+
+  describe '.rotate!' do
+    subject(:rotate) { described_class.rotate! }
+
+    let_it_be(:first_key) { create(:cloud_connector_keys, created_at: Time.current - 1.minute) }
+
+    context 'when fewer than 2 keys exist' do
+      it { expect { rotate }.to raise_error(StandardError, 'Key rotation requires exactly 2 keys, found 1') }
+    end
+
+    context 'when more than 2 keys exist' do
+      before do
+        create_list(:cloud_connector_keys, 2)
+      end
+
+      it { expect { rotate }.to raise_error(StandardError, 'Key rotation requires exactly 2 keys, found 3') }
+    end
+
+    context 'when exactly 2 keys exist' do
+      let_it_be(:second_key) { create(:cloud_connector_keys) }
+
+      it 'swaps the keys' do
+        expect { rotate }.to change { described_class.valid.pluck(:secret_key) }
+          .from([first_key.secret_key, second_key.secret_key])
+          .to([second_key.secret_key, first_key.secret_key])
+      end
+    end
+  end
+
+  describe '.trim!' do
+    subject(:trimmed_key) { described_class.trim! }
+
+    context 'with no keys' do
+      it { is_expected.to be_nil }
+    end
+
+    context 'with a single key' do
+      let_it_be(:key) { create(:cloud_connector_keys) }
+
+      it { expect { trimmed_key }.to raise_error(StandardError, 'Refusing to remove single key, as it is in use') }
+    end
+
+    context 'with more than 1 key' do
+      let_it_be(:first_key) { create(:cloud_connector_keys, created_at: Time.current - 1.minute) }
+      let_it_be(:second_key) { create(:cloud_connector_keys) }
+
+      it 'removes the newest key' do
+        expect(trimmed_key).to eq(second_key)
+        expect(described_class.all).to contain_exactly(first_key)
+      end
+    end
+  end
+
+  describe '#truncated_pem' do
+    subject(:short_pem) { create(:cloud_connector_keys).truncated_pem }
+
+    it 'truncates the PEM string to 90 characters' do
+      expect(short_pem.length).to eq(90)
+    end
+  end
 end
diff --git a/ee/spec/tasks/cloud_connector/keys_rake_spec.rb b/ee/spec/tasks/cloud_connector/keys_rake_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2dbfb387c5c1a241fe4d428e645bc266af6333a0
--- /dev/null
+++ b/ee/spec/tasks/cloud_connector/keys_rake_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'cloud_connector:keys', :silence_stdout, feature_category: :cloud_connector do
+  include RakeHelpers
+
+  let(:key) { build(:cloud_connector_keys) }
+
+  before do
+    Rake.application.rake_require('ee/lib/tasks/cloud_connector/keys', [Rails.root.to_s])
+  end
+
+  shared_examples 'handles errors' do
+    it { expect { task }.to raise_error(SystemExit) }
+  end
+
+  describe 'list' do
+    subject(:task) { run_rake_task('cloud_connector:keys:list', args) }
+
+    let(:args) { [] }
+
+    before do
+      allow(CloudConnector::Keys).to receive(:valid).and_return([key])
+    end
+
+    context 'without arguments' do
+      it 'lists PEM private keys in truncated form' do
+        expect(key).to receive(:truncated_pem).and_return('ABC...')
+
+        expect { task }.to output(/ABC.../).to_stdout
+      end
+    end
+
+    context 'with truncate: false' do
+      let(:args) { ['false'] }
+
+      it 'lists full PEM private keys' do
+        expect(key).to receive(:secret_key).and_return('ABCDEF')
+
+        expect { task }.to output(/ABCDEF/).to_stdout
+      end
+    end
+
+    context 'when an error occurs' do
+      before do
+        allow(CloudConnector::Keys).to receive(:valid).and_raise(StandardError)
+      end
+
+      include_examples 'handles errors'
+    end
+  end
+
+  describe 'create' do
+    subject(:task) { run_rake_task('cloud_connector:keys:create') }
+
+    it 'creates a new key' do
+      expect(CloudConnector::Keys).to receive(:create_new_key!).and_return(key)
+      expect(key).to receive(:truncated_pem).and_return('ABCDEF')
+
+      expect { task }.to output(/Key created: ABCDEF/).to_stdout
+    end
+
+    context 'when an error occurs' do
+      before do
+        allow(CloudConnector::Keys).to receive(:create_new_key!).and_raise(StandardError)
+      end
+
+      include_examples 'handles errors'
+    end
+  end
+
+  describe 'rotate' do
+    subject(:task) { run_rake_task('cloud_connector:keys:rotate') }
+
+    it 'rotates keys' do
+      expect(CloudConnector::Keys).to receive(:rotate!)
+
+      expect { task }.to output(/Keys swapped successfully/).to_stdout
+    end
+
+    context 'when an error occurs' do
+      before do
+        allow(CloudConnector::Keys).to receive(:rotate!).and_raise(StandardError)
+      end
+
+      include_examples 'handles errors'
+    end
+  end
+
+  describe 'trim' do
+    subject(:task) { run_rake_task('cloud_connector:keys:trim') }
+
+    it 'trims keys' do
+      expect(CloudConnector::Keys).to receive(:trim!).and_return(key)
+      expect(key).to receive(:truncated_pem).and_return('ABCDEF')
+
+      expect { task }.to output(/Key removed: ABCDEF/).to_stdout
+    end
+
+    context 'when an error occurs' do
+      before do
+        allow(CloudConnector::Keys).to receive(:trim!).and_raise(StandardError)
+      end
+
+      include_examples 'handles errors'
+    end
+  end
+end