diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb
index 618dc2da05cb3420520f6ff5041166e66257f2d8..c4071ac32717efb2bcd9da5ea1bc3258cd2ea153 100644
--- a/app/models/ci/job_token/allowlist.rb
+++ b/app/models/ci/job_token/allowlist.rb
@@ -2,17 +2,24 @@
 module Ci
   module JobToken
     class Allowlist
-      def initialize(source_project, direction:)
+      def initialize(source_project, direction: :inbound)
         @source_project = source_project
         @direction = direction
       end
 
-      def includes?(target_project)
+      def includes_project?(target_project)
         source_links
           .with_target(target_project)
           .exists?
       end
 
+      def includes_group?(target_project)
+        allowlist_group_ids = group_links.pluck(:target_group_id)
+        target_project_group_path_ids = target_project.parent_groups.map(&:id)
+
+        allowlist_group_ids.intersect?(target_project_group_path_ids)
+      end
+
       def projects
         Project.from_union(target_projects, remove_duplicates: false)
       end
@@ -34,6 +41,11 @@ def source_links
           .where(direction: @direction)
       end
 
+      def group_links
+        Ci::JobToken::GroupScopeLink
+          .with_source(@source_project)
+      end
+
       def target_project_ids
         source_links
           # pluck needed to avoid ci and main db join
diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb
index a29d4fee29944d37b2523e34267d657ebce0b70f..a512c9417ff296f348b35e49589f7c359a7025f5 100644
--- a/app/models/ci/job_token/scope.rb
+++ b/app/models/ci/job_token/scope.rb
@@ -39,15 +39,6 @@ def inbound_projects
         inbound_allowlist.projects
       end
 
-      def add!(added_project, user:, direction:)
-        case direction
-        when :inbound
-          inbound_allowlist.add!(added_project, user: user)
-        when :outbound
-          outbound_allowlist.add!(added_project, user: user)
-        end
-      end
-
       private
 
       def outbound_accessible?(accessed_project)
@@ -56,21 +47,27 @@ def outbound_accessible?(accessed_project)
 
         return true unless accessed_project.private?
 
-        outbound_allowlist.includes?(accessed_project)
+        outbound_allowlist.includes_project?(accessed_project)
       end
 
       def inbound_accessible?(accessed_project)
         # if the setting is disabled any project is considered to be in scope.
         return true unless accessed_project.ci_inbound_job_token_scope_enabled?
 
-        inbound_linked_as_accessible?(accessed_project)
+        inbound_linked_as_accessible?(accessed_project) ||
+          (::Feature.enabled?(:ci_job_token_groups_allowlist, accessed_project) &&
+          group_linked_as_accessible?(accessed_project))
       end
 
       # We don't check the inbound allowlist here. That is because
       # the access check starts from the current project but the inbound
       # allowlist contains projects that can access the current project.
       def inbound_linked_as_accessible?(accessed_project)
-        inbound_accessible_projects(accessed_project).includes?(current_project)
+        inbound_accessible_projects(accessed_project).includes_project?(current_project)
+      end
+
+      def group_linked_as_accessible?(accessed_project)
+        Ci::JobToken::Allowlist.new(accessed_project).includes_group?(current_project)
       end
 
       def inbound_accessible_projects(accessed_project)
diff --git a/app/models/project.rb b/app/models/project.rb
index 7012cb7b25bcc84be66ce3ee9e7e2ba4631b4bf7..8af1250da427dd450508d1856ee1faab29d155d9 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -3092,6 +3092,10 @@ def visible_group_links(for_user:)
     end
   end
 
+  def parent_groups
+    Gitlab::ObjectHierarchy.new(Group.where(id: group)).base_and_ancestors
+  end
+
   def enforced_runner_token_expiration_interval
     all_parent_groups = Gitlab::ObjectHierarchy.new(Group.where(id: group)).base_and_ancestors
     all_group_settings = NamespaceSetting.where(namespace_id: all_parent_groups)
diff --git a/config/feature_flags/development/ci_job_token_groups_allowlist.yml b/config/feature_flags/development/ci_job_token_groups_allowlist.yml
new file mode 100644
index 0000000000000000000000000000000000000000..85d44d39cb685a4921f962e2f70ab66412a14fa2
--- /dev/null
+++ b/config/feature_flags/development/ci_job_token_groups_allowlist.yml
@@ -0,0 +1,8 @@
+---
+name: ci_job_token_groups_allowlist
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/142441
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/439611
+milestone: '16.9'
+type: development
+group: group::pipeline security
+default_enabled: false
\ No newline at end of file
diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb
index 3d29a637d68fa92782910456f19afc0ea7c0a13a..feea8758490d8a3ca3fff1e2e4fa20f71b05b731 100644
--- a/spec/models/ci/job_token/allowlist_spec.rb
+++ b/spec/models/ci/job_token/allowlist_spec.rb
@@ -64,8 +64,8 @@
     end
   end
 
-  describe '#includes?' do
-    subject { allowlist.includes?(includes_project) }
+  describe '#includes_project?' do
+    subject { allowlist.includes_project?(includes_project) }
 
     context 'without scoped projects' do
       let(:unscoped_project) { build(:project) }
@@ -102,5 +102,42 @@
         it { is_expected.to be result }
       end
     end
+
+    describe '#includes_group' do
+      subject { allowlist.includes_group?(target_project) }
+
+      let_it_be(:target_group) { create(:group) }
+      let_it_be(:target_project) do
+        create(:project,
+          ci_inbound_job_token_scope_enabled: true,
+          group: target_group
+        )
+      end
+
+      context 'without scoped groups' do
+        let_it_be(:unscoped_project) { build(:project) }
+
+        where(:source_project, :result) do
+          ref(:unscoped_project) | false
+        end
+
+        with_them do
+          it { is_expected.to be result }
+        end
+      end
+
+      context 'with a group in each allowlist' do
+        include_context 'with accessible and inaccessible project groups'
+
+        where(:source_project, :result) do
+          ref(:project_with_target_project_group_in_allowlist) | true
+          ref(:project_wo_target_project_group_in_allowlist) | false
+        end
+
+        with_them do
+          it { is_expected.to be result }
+        end
+      end
+    end
   end
 end
diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb
index adb9f461f63a76fc5e184787cde5994e2fadf497..f2ad674b3f530d12bd328c31a48eaf6875b8b663 100644
--- a/spec/models/ci/job_token/scope_spec.rb
+++ b/spec/models/ci/job_token/scope_spec.rb
@@ -57,39 +57,46 @@
     end
   end
 
-  describe 'add!' do
-    let_it_be(:new_project) { create(:project) }
+  describe 'accessible?' do
+    subject { scope.accessible?(accessed_project) }
 
-    subject { scope.add!(new_project, direction: direction, user: user) }
+    context 'with groups in allowlist' do
+      let_it_be(:target_group) { create(:group) }
+      let_it_be(:target_project) do
+        create(:project,
+          ci_inbound_job_token_scope_enabled: true,
+          group: target_group
+        )
+      end
 
-    [:inbound, :outbound].each do |d|
-      context "with #{d}" do
-        let(:direction) { d }
+      let(:scope) { described_class.new(target_project) }
 
-        it 'adds the project' do
-          subject
+      include_context 'with accessible and inaccessible project groups'
 
-          expect(scope.send("#{direction}_projects")).to contain_exactly(current_project, new_project)
-        end
+      where(:accessed_project, :result) do
+        ref(:project_with_target_project_group_in_allowlist)            | true
+        ref(:project_wo_target_project_group_in_allowlist)              | false
       end
-    end
 
-    # Context and before block can go away leaving just the example in 16.0
-    context 'with inbound only enabled' do
-      before do
-        project.ci_cd_settings.update!(job_token_scope_enabled: false)
+      with_them do
+        it { is_expected.to eq(result) }
       end
 
-      it 'provides access' do
-        expect do
-          scope.add!(new_project, direction: :inbound, user: user)
-        end.to change { described_class.new(new_project).accessible?(current_project) }.from(false).to(true)
+      context 'when ci_job_token_groups_allowlist feature flag is disabled' do
+        before do
+          stub_feature_flags(ci_job_token_groups_allowlist: false)
+        end
+
+        where(:accessed_project, :result) do
+          ref(:project_with_target_project_group_in_allowlist) | false
+          ref(:project_wo_target_project_group_in_allowlist) | false
+        end
+
+        with_them do
+          it { is_expected.to eq(result) }
+        end
       end
     end
-  end
-
-  describe 'accessible?' do
-    subject { scope.accessible?(accessed_project) }
 
     context 'with inbound and outbound scopes enabled' do
       context 'when inbound and outbound access setup' do
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index d7037df66782be8ba8fd23d6f2ec27042e629436..580ecce1f7d0ed787be80fb3074e711c12c3fd9e 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -9152,4 +9152,26 @@ def create_build(new_pipeline = pipeline, name = 'test')
       let_it_be(:model) { create(:project, creator: parent) }
     end
   end
+
+  describe '#parent_groups' do
+    context 'when project has parent groups' do
+      let_it_be(:nested_group) { create(:group, parent: group) }
+      let_it_be_with_reload(:group) { create(:group, name: 'foo', parent: nested_group) }
+      let_it_be(:project) { create(:project, group: group) }
+
+      it 'builds an groups path' do
+        groups_path = project.parent_groups
+        expect(groups_path).to match_array([group, nested_group])
+      end
+    end
+
+    context 'when project does not have a parent group' do
+      let_it_be(:project) { create(:project) }
+
+      it 'builds an empty path' do
+        groups_path = project.parent_groups
+        expect(groups_path).to eq([])
+      end
+    end
+  end
 end
diff --git a/spec/support/helpers/ci/job_token_scope_helpers.rb b/spec/support/helpers/ci/job_token_scope_helpers.rb
index 1d71356917e385b5e04f15e46ebbf21bb2b0d48c..90e415583d000b0fddc1b55fd0b5e00694951214 100644
--- a/spec/support/helpers/ci/job_token_scope_helpers.rb
+++ b/spec/support/helpers/ci/job_token_scope_helpers.rb
@@ -17,6 +17,25 @@ def create_project_in_allowlist(root_project, direction:, target_project: nil)
       included_project
     end
 
+    def create_project_with_group_allowlist(target_project, accessible_project: nil)
+      included_project = accessible_project || create(:project,
+        ci_inbound_job_token_scope_enabled: true
+      )
+      create(
+        :ci_job_token_group_scope_link,
+        source_project: included_project,
+        target_group: target_project.group
+      )
+
+      included_project
+    end
+
+    def create_project_without_group_allowlist(accessible_project: nil)
+      accessible_project || create(:project,
+        ci_inbound_job_token_scope_enabled: true
+      )
+    end
+
     def create_project_in_both_allowlists(root_project)
       create_project_in_allowlist(root_project, direction: :outbound).tap do |new_project|
         create_project_in_allowlist(root_project, target_project: new_project, direction: :inbound)
diff --git a/spec/support/shared_contexts/models/ci/job_token_scope.rb b/spec/support/shared_contexts/models/ci/job_token_scope.rb
index a33a3e09c71df8cc9ed3a0a6a88ddaa03f29f509..c363ff443baeef7a5100d0394fc86d92a49e9f58 100644
--- a/spec/support/shared_contexts/models/ci/job_token_scope.rb
+++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb
@@ -14,6 +14,11 @@
   include_context 'with inaccessible projects'
 end
 
+RSpec.shared_context 'with accessible and inaccessible project groups' do
+  let_it_be(:project_with_target_project_group_in_allowlist) { create_project_with_group_allowlist(target_project) }
+  let_it_be(:project_wo_target_project_group_in_allowlist) { create_project_without_group_allowlist }
+end
+
 RSpec.shared_context 'with inaccessible projects' do
   let_it_be(:inbound_allowlist_project) { create_project_in_allowlist(source_project, direction: :inbound) }