diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 7b2cf131fce95f0678f092570cd8ff6e752256bc..4e34094b52c048841d2a7ed6b6ed661c79bfb4fd 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -11,6 +11,7 @@ module NotesActions
 
   included do
     before_action :set_polling_interval_header, only: [:index]
+    before_action :require_last_fetched_at_header!, only: [:index]
     before_action :require_noteable!, only: [:index, :create]
     before_action :authorize_admin_note!, only: [:update, :destroy]
     before_action :note_project, only: [:create]
@@ -262,6 +263,12 @@ def require_noteable!
     render_404 unless noteable
   end
 
+  def require_last_fetched_at_header!
+    return if request.headers['X-Last-Fetched-At'].present? || Feature.disabled?(:require_notes_last_fetched_at)
+
+    render json: { message: 'X-Last-Fetched-At header is required' }, status: :bad_request
+  end
+
   def last_fetched_at
     microseconds = request.headers['X-Last-Fetched-At'].to_i
 
diff --git a/config/feature_flags/development/require_notes_last_fetched_at.yml b/config/feature_flags/development/require_notes_last_fetched_at.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7ded6f16543e7edca367f1307660f193675fce4f
--- /dev/null
+++ b/config/feature_flags/development/require_notes_last_fetched_at.yml
@@ -0,0 +1,8 @@
+---
+name: require_notes_last_fetched_at
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127763
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/419829
+milestone: '16.3'
+type: development
+group: group::project management
+default_enabled: false
diff --git a/ee/spec/controllers/groups/epics/notes_controller_spec.rb b/ee/spec/controllers/groups/epics/notes_controller_spec.rb
index f8854249e28db0b3942a5d151b21a826afab763a..493a95dc544899e5baad9b44e6267fe3de9188f9 100644
--- a/ee/spec/controllers/groups/epics/notes_controller_spec.rb
+++ b/ee/spec/controllers/groups/epics/notes_controller_spec.rb
@@ -28,6 +28,8 @@
       group.add_developer(user)
       sign_in(user)
       note
+
+      request.headers['X-Last-Fetched-At'] = 0
     end
 
     it 'responds with array of notes' do
diff --git a/ee/spec/controllers/projects/security/vulnerabilities/notes_controller_spec.rb b/ee/spec/controllers/projects/security/vulnerabilities/notes_controller_spec.rb
index 0a59047e4398fe66b1f5ace7ebe39f2775fad539..c1b193bb4106b843b9244ab486e3e2405343fc12 100644
--- a/ee/spec/controllers/projects/security/vulnerabilities/notes_controller_spec.rb
+++ b/ee/spec/controllers/projects/security/vulnerabilities/notes_controller_spec.rb
@@ -15,6 +15,8 @@
 
   describe 'GET index' do
     subject(:view_all_notes) do
+      request.headers['X-Last-Fetched-At'] = 0
+
       get :index, params: { namespace_id: project.namespace, project_id: project, vulnerability_id: vulnerability }
     end
 
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index 940f6fed906d83f121fba617f4d8feac1af660c9..3dcd6b040401fb28abf339ac436755bbd3f43879 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -18,7 +18,7 @@
     }
   end
 
-  describe 'GET index' do
+  describe 'GET index', :freeze_time do
     let(:request_params) do
       {
         namespace_id: project.namespace,
@@ -31,10 +31,13 @@
 
     let(:parsed_response) { json_response.with_indifferent_access }
     let(:note_json) { parsed_response[:notes].first }
+    let(:last_fetched_at) { Time.zone.at(3.hours.ago.to_i) }
 
     before do
       sign_in(user)
       project.add_developer(user)
+
+      request.headers['X-Last-Fetched-At'] = microseconds(last_fetched_at)
     end
 
     specify { expect(get(:index, params: request_params)).to have_request_urgency(:medium) }
@@ -46,10 +49,6 @@
     end
 
     it 'passes last_fetched_at from headers to NotesFinder and MergeIntoNotesService' do
-      last_fetched_at = Time.zone.at(3.hours.ago.to_i) # remove nanoseconds
-
-      request.headers['X-Last-Fetched-At'] = microseconds(last_fetched_at)
-
       expect(NotesFinder).to receive(:new)
         .with(anything, hash_including(last_fetched_at: last_fetched_at))
         .and_call_original
@@ -61,6 +60,28 @@
       get :index, params: request_params
     end
 
+    it 'returns status 400 when last_fetched_at is not present' do
+      request.headers['X-Last-Fetched-At'] = nil
+
+      get :index, params: request_params
+
+      expect(response).to have_gitlab_http_status(:bad_request)
+    end
+
+    context 'when require_notes_last_fetched_at is disabled' do
+      before do
+        stub_feature_flags(require_notes_last_fetched_at: false)
+      end
+
+      it 'returns status 200 when last_fetched_at is not present' do
+        request.headers['X-Last-Fetched-At'] = nil
+
+        get :index, params: request_params
+
+        expect(response).to have_gitlab_http_status(:ok)
+      end
+    end
+
     context 'when user notes_filter is present' do
       let(:notes_json) { parsed_response[:notes] }
       let!(:comment) { create(:note, noteable: issue, project: project) }
diff --git a/spec/controllers/snippets/notes_controller_spec.rb b/spec/controllers/snippets/notes_controller_spec.rb
index 578973d5b3db526697465ecf2eae74bbd3575b3f..834415a5c87ab26d3e23e150ec2a077e3873085c 100644
--- a/spec/controllers/snippets/notes_controller_spec.rb
+++ b/spec/controllers/snippets/notes_controller_spec.rb
@@ -14,6 +14,10 @@
   let(:note_on_public)   { create(:note_on_personal_snippet, noteable: public_snippet) }
 
   describe 'GET index' do
+    before do
+      request.headers['X-Last-Fetched-At'] = 0
+    end
+
     context 'when a snippet is public' do
       before do
         note_on_public
diff --git a/spec/requests/projects/noteable_notes_spec.rb b/spec/requests/projects/noteable_notes_spec.rb
index 55540447da0633c5f0f9fc211b2c4825e4172565..a490e0596807bb283e8fd244d11073d114a43c52 100644
--- a/spec/requests/projects/noteable_notes_spec.rb
+++ b/spec/requests/projects/noteable_notes_spec.rb
@@ -14,6 +14,8 @@
     let(:response_etag) { response.headers['ETag'] }
     let(:stored_etag) { "W/\"#{etag_store.get(notes_path)}\"" }
 
+    let(:default_headers) { { 'X-Last-Fetched-At' => 0 } }
+
     before do
       login_as(user)
     end
@@ -21,7 +23,7 @@
     it 'does not set a Gitlab::EtagCaching ETag if there is a note' do
       create(:note_on_merge_request, noteable: merge_request, project: merge_request.project)
 
-      get notes_path
+      get notes_path, headers: default_headers
 
       expect(response).to have_gitlab_http_status(:ok)
 
@@ -31,7 +33,7 @@
     end
 
     it 'sets a Gitlab::EtagCaching ETag if there is no note' do
-      get notes_path
+      get notes_path, headers: default_headers
 
       expect(response).to have_gitlab_http_status(:ok)
       expect(response_etag).to eq(stored_etag)
@@ -68,7 +70,7 @@
         )
       )
 
-      get notes_path, headers: { "if-none-match": stored_etag }
+      get notes_path, headers: default_headers.merge("if-none-match": stored_etag)
 
       expect(response).to have_gitlab_http_status(:not_modified)
     end