diff --git a/app/models/repository.rb b/app/models/repository.rb
index 4a8c097528298e995ccd037e558074e0bb8a1ede..55e723c8b97df944d7d2a6db300e881dca95a05d 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -885,6 +885,11 @@ def commit_files(user, **options)
       options[:start_repository] = start_project.repository.raw_repository
     end
 
+    skip_target_sha = options.delete(:skip_target_sha)
+    unless skip_target_sha || Feature.disabled?(:validate_target_sha_in_user_commit_files, project)
+      options[:target_sha] = self.commit(options[:branch_name])&.sha
+    end
+
     with_cache_hooks { raw.commit_files(user, **options) }
   end
 
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index cd554f1055b0702b9e822d256a30fdcd9b41e2dd..58da5195c44477a166a1c45e159ee30f120e5d89 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -124,5 +124,9 @@ def build_actions_from_params(_snippet)
     def restricted_files_actions
       :create
     end
+
+    def commit_attrs(snippet, msg)
+      super.merge(skip_target_sha: true)
+    end
   end
 end
diff --git a/config/feature_flags/gitlab_com_derisk/validate_target_sha_in_user_commit_files.yml b/config/feature_flags/gitlab_com_derisk/validate_target_sha_in_user_commit_files.yml
new file mode 100644
index 0000000000000000000000000000000000000000..32d49121e884fa44e2e55d4e9ad69ab59ba57e22
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/validate_target_sha_in_user_commit_files.yml
@@ -0,0 +1,9 @@
+---
+name: validate_target_sha_in_user_commit_files
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/384017
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161981
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/479424
+milestone: '17.4'
+group: group::source code
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/ee/spec/models/remote_mirror_spec.rb b/ee/spec/models/remote_mirror_spec.rb
index 2af44015e55c33cfd862f4dc0192a29c107a76f1..ae8c8acc1553b650a2aafdd2778dd8b09a0cc366 100644
--- a/ee/spec/models/remote_mirror_spec.rb
+++ b/ee/spec/models/remote_mirror_spec.rb
@@ -68,8 +68,8 @@
       end
 
       before do
-        project.repository.create_branch('matched', create_commit)
-        project.repository.create_branch('mismatched', create_commit)
+        project.repository.create_branch(branch_name = 'matched', create_commit(branch_name))
+        project.repository.create_branch(branch_name = 'mismatched', create_commit(branch_name))
       end
 
       it 'only sync matched and recently updated branch' do
@@ -80,10 +80,10 @@
     end
   end
 
-  def create_commit
+  def create_commit(branch_name)
     project.repository.commit_files(
       user,
-      branch_name: 'HEAD',
+      branch_name: branch_name,
       message: 'commit message',
       actions: []
     )
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 064368baf1f081965de67c4ae9d3f96711282bd7..092698785591551e910432a0fd97637691bce324 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -1014,6 +1014,7 @@ def bundle_to_disk(save_path)
       # @param [String] start_sha: The sha to be used as the parent of the commit.
       # @param [Gitlab::Git::Repository] start_repository: The repository that contains the start branch or sha. Defaults to use this repository.
       # @param [Boolean] force: Force update the branch.
+      # @param [String] target_sha: The latest sha of the target branch (optional). Used to prevent races in updates between different clients.
       # @return [Gitlab::Git::OperationService::BranchUpdate]
       #
       # rubocop:disable Metrics/ParameterLists
@@ -1021,12 +1022,12 @@ def commit_files(
         user, branch_name:, message:, actions:,
         author_email: nil, author_name: nil,
         start_branch_name: nil, start_sha: nil, start_repository: nil,
-        force: false, sign: true)
+        force: false, sign: true, target_sha: nil)
 
         wrapped_gitaly_errors do
           gitaly_operation_client.user_commit_files(user, branch_name,
-            message, actions, author_email, author_name,
-            start_branch_name, start_repository, force, start_sha, sign)
+            message, actions, author_email, author_name, start_branch_name,
+            start_repository, force, start_sha, sign, target_sha)
         end
       end
       # rubocop:enable Metrics/ParameterLists
diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb
index 6797890f8d0ffbed212aae9775fe13decb9f7f5b..fe352ade7978cffd49603bb8b0c0cc3f601ace5a 100644
--- a/lib/gitlab/gitaly_client/operation_service.rb
+++ b/lib/gitlab/gitaly_client/operation_service.rb
@@ -507,12 +507,12 @@ def user_update_submodule(user:, submodule:, commit_sha:, branch:, message:)
 
       # rubocop:disable Metrics/ParameterLists
       def user_commit_files(
-        user, branch_name, commit_message, actions, author_email, author_name,
-        start_branch_name, start_repository, force = false, start_sha = nil, sign = true)
+        user, branch_name, commit_message, actions, author_email, author_name, start_branch_name,
+        start_repository, force = false, start_sha = nil, sign = true, target_sha = nil)
         req_enum = Enumerator.new do |y|
           header = user_commit_files_request_header(user, branch_name,
-            commit_message, actions, author_email, author_name,
-            start_branch_name, start_repository, force, start_sha, sign)
+            commit_message, actions, author_email, author_name, start_branch_name,
+            start_repository, force, start_sha, sign, target_sha)
 
           y.yield Gitaly::UserCommitFilesRequest.new(header: header)
 
@@ -561,13 +561,7 @@ def user_commit_files(
         when :index_update
           raise Gitlab::Git::Index::IndexError, index_error_message(detailed_error.index_update)
         else
-          # Some invalid path errors are caught by Gitaly directly and returned
-          # as an :index_update error, while others are found by libgit2 and
-          # come as generic errors. We need to convert the latter as IndexErrors
-          # as well.
-          if e.to_status.details.start_with?('invalid path')
-            raise Gitlab::Git::Index::IndexError, e.to_status.details
-          end
+          handle_undetailed_bad_status_errors(e)
 
           raise e
         end
@@ -614,7 +608,7 @@ def consume_final_message(response_enum)
       # rubocop:disable Metrics/ParameterLists
       def user_commit_files_request_header(
         user, branch_name, commit_message, actions, author_email, author_name,
-        start_branch_name, start_repository, force, start_sha, sign)
+        start_branch_name, start_repository, force, start_sha, sign, target_sha)
 
         Gitaly::UserCommitFilesRequestHeader.new(
           repository: @gitaly_repo,
@@ -628,6 +622,7 @@ def user_commit_files_request_header(
           force: force,
           start_sha: encode_binary(start_sha),
           sign: sign,
+          expected_old_oid: target_sha,
           timestamp: Google::Protobuf::Timestamp.new(seconds: Time.now.utc.to_i)
         )
       end
@@ -673,6 +668,18 @@ def index_error_message(index_error)
           "Unknown error performing git operation"
         end
       end
+
+      def handle_undetailed_bad_status_errors(error)
+        # Some invalid path errors are caught by Gitaly directly and returned
+        # as an :index_update error, while others are found by libgit2 and
+        # come as generic errors. We need to convert the latter as IndexErrors
+        # as well.
+        if error.to_status.details.start_with?('invalid path')
+          raise Gitlab::Git::Index::IndexError, error.to_status.details
+        elsif error.is_a?(GRPC::InvalidArgument) && error.to_status.details.include?('expected old object ID')
+          raise Gitlab::Git::CommandError, error
+        end
+      end
     end
   end
 end
diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb
index a84a3294465ccea2558dfb14c5ce06a443d90a49..ee921bf175f9b87b59cf0d24ddce9043c9f21a43 100644
--- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb
@@ -1180,17 +1180,19 @@
     let(:force) { false }
     let(:start_sha) { nil }
     let(:sign) { true }
+    let(:target_sha) { nil }
 
     subject do
       client.user_commit_files(
         user, 'my-branch', 'Commit files message', [], 'janedoe@example.com', 'Jane Doe',
-        'master', repository, force, start_sha, sign)
+        'master', repository, force, start_sha, sign, target_sha)
     end
 
     context 'when UserCommitFiles RPC is called' do
       let(:force) { true }
       let(:start_sha) { project.commit.id }
       let(:sign) { false }
+      let(:target_sha) { 'target_sha' }
 
       it 'successfully builds the header' do
         expect_any_instance_of(Gitaly::OperationService::Stub).to receive(:user_commit_files) do |_, req_enum|
@@ -1199,6 +1201,7 @@
           expect(header.force).to eq(force)
           expect(header.start_sha).to eq(start_sha)
           expect(header.sign).to eq(sign)
+          expect(header.expected_old_oid).to eq(target_sha)
         end.and_return(Gitaly::UserCommitFilesResponse.new)
 
         subject
@@ -1441,6 +1444,32 @@
           end
         end
       end
+
+      context 'with an invalid target_sha' do
+        context 'when the target_sha is not in a valid format' do
+          let(:target_sha) { 'asdf' }
+
+          it 'raises CommandError' do
+            expect { subject }.to raise_error(Gitlab::Git::CommandError)
+          end
+        end
+
+        context 'when the target_sha is valid but not present in the repo' do
+          let(:target_sha) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff0' }
+
+          it 'raises CommandError' do
+            expect { subject }.to raise_error(Gitlab::Git::CommandError)
+          end
+        end
+
+        context 'when the target_sha is present in the repo but is not the latest' do
+          let(:target_sha) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' }
+
+          it 'raises FailedPrecondition' do
+            expect { subject }.to raise_error(GRPC::FailedPrecondition)
+          end
+        end
+      end
     end
   end
 
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 58ed4a56a007868fddd04b05b46bd2481d357923..bc912f185a23a9f28772fec083410e96efdb2f1c 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -4307,19 +4307,16 @@ def update_storages(storage_hash)
   end
 
   describe '#commit_files' do
-    let(:project) { create(:project, :empty_repo) }
-
-    it 'calls UserCommitFiles RPC' do
-      expect_next_instance_of(Gitlab::GitalyClient::OperationService) do |client|
-        expect(client).to receive(:user_commit_files).with(
-          user, 'extra-branch', 'commit message', [],
-          'author email', 'author name', nil, nil, true, nil, false
-        )
-      end
+    let_it_be(:project) { create(:project, :repository) }
+    let(:target_sha) { repository.commit('master').sha }
+    let(:expected_params) do
+      [user, 'master', 'commit message', [], 'author email', 'author name', nil, nil, true, nil, false, target_sha]
+    end
 
+    subject do
       repository.commit_files(
         user,
-        branch_name: 'extra-branch',
+        branch_name: 'master',
         message: 'commit message',
         author_name: 'author name',
         author_email: 'author email',
@@ -4328,5 +4325,44 @@ def update_storages(storage_hash)
         sign: false
       )
     end
+
+    it 'finds and passes the branches target_sha' do
+      expect_next_instance_of(Gitlab::GitalyClient::OperationService) do |client|
+        expect(client).to receive(:user_commit_files).with(*expected_params)
+      end
+
+      subject
+    end
+
+    context 'when validate_target_sha_in_user_commit_files feature flag is disabled' do
+      let_it_be(:project) { create(:project, :repository) }
+      let(:target_sha) { nil }
+
+      before do
+        stub_feature_flags(validate_target_sha_in_user_commit_files: false)
+      end
+
+      it 'does not find or pass the branches target_sha' do
+        expect_next_instance_of(Gitlab::GitalyClient::OperationService) do |client|
+          expect(client).to receive(:user_commit_files).with(*expected_params)
+        end
+        expect(repository).not_to receive(:commit)
+
+        subject
+      end
+    end
+
+    context 'with an empty branch' do
+      let_it_be(:project) { create(:project, :empty_repo) }
+      let(:target_sha) { nil }
+
+      it 'calls UserCommitFiles RPC' do
+        expect_next_instance_of(Gitlab::GitalyClient::OperationService) do |client|
+          expect(client).to receive(:user_commit_files).with(*expected_params)
+        end
+
+        subject
+      end
+    end
   end
 end
diff --git a/spec/services/design_management/save_designs_service_spec.rb b/spec/services/design_management/save_designs_service_spec.rb
index bbde629e345ea824a7959f036d6ea32c86143a7f..c8ea024282cdd28e6815b3bed7a6a42c45c330e3 100644
--- a/spec/services/design_management/save_designs_service_spec.rb
+++ b/spec/services/design_management/save_designs_service_spec.rb
@@ -366,7 +366,7 @@ def repository_exists
           expect { service.execute }
             .to change { issue.designs.count }.from(0).to(2)
             .and change { DesignManagement::Version.count }.by(1)
-            .and change { Gitlab::GitalyClient.get_request_count }.by(3)
+            .and change { Gitlab::GitalyClient.get_request_count }.by(4)
             .and change { commit_count }.by(1)
             .and trigger_internal_events(Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_DESIGNS_ADDED)
               .twice.with(user: user, project: project, category: 'InternalEventTracking')
diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb
index d7546ac08f69fbf2bcf7e1e283ce5a89e17c2043..77df0b5921d6d41ac65020254d5de33195661c22 100644
--- a/spec/services/repositories/changelog_service_spec.rb
+++ b/spec/services/repositories/changelog_service_spec.rb
@@ -265,6 +265,8 @@
   end
 
   def create_commit(project, user, params)
+    RequestStore.clear!
+
     params = { start_branch: 'master', branch_name: 'master' }.merge(params)
     Files::MultiService.new(project, user, params).execute.fetch(:result)
   end
diff --git a/spec/services/snippets/create_service_spec.rb b/spec/services/snippets/create_service_spec.rb
index 8c288e94f73ca01c194e6ddbd6dfb79ecb896665..5254a318221f5ed8d2f3dcb1f8527d32b67c1a11 100644
--- a/spec/services/snippets/create_service_spec.rb
+++ b/spec/services/snippets/create_service_spec.rb
@@ -118,6 +118,14 @@
         expect(blob.data).to eq base_opts[:content]
       end
 
+      it 'passes along correct commit attributes' do
+        expect_next_instance_of(Repository) do |repository|
+          expect(repository).to receive(:commit_files).with(anything, a_hash_including(skip_target_sha: true))
+        end
+
+        subject
+      end
+
       context 'when repository creation action fails' do
         before do
           allow_next_instance_of(Snippet) do |instance|
diff --git a/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb b/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb
index 25a72d46a7b1ef105491cea3d5a4f400d9f6c81f..31811527259bd89b7515f5845270b84d38eb99b8 100644
--- a/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/security/ci_configuration/create_service_shared_examples.rb
@@ -154,7 +154,7 @@
         let_it_be(:repository) { project.repository }
 
         it 'is successful' do
-          expect(repository).to receive(:commit).and_return(nil)
+          expect(repository).to receive(:commit).twice.and_return(nil)
           expect(result.status).to eq(:success)
         end
       end