diff --git a/ee/app/models/ee/namespace.rb b/ee/app/models/ee/namespace.rb
index 6f7a65d1117e1ce89722b3d1807fba54f5d51b0f..d68057fb5813b68b8b068b686ef97b86873f4af2 100644
--- a/ee/app/models/ee/namespace.rb
+++ b/ee/app/models/ee/namespace.rb
@@ -33,7 +33,7 @@ module Namespace
         foreign_key: :namespace_id,
         inverse_of: :namespace
       has_one :upcoming_reconciliation, inverse_of: :namespace, class_name: "GitlabSubscriptions::UpcomingReconciliation"
-      has_one :system_access_microsoft_application, class_name: 'SystemAccess::MicrosoftApplication'
+      has_one :system_access_microsoft_application, class_name: '::SystemAccess::MicrosoftApplication'
 
       has_many :automation_rules, class_name: '::Automation::Rule'
       has_many :ci_minutes_additional_packs, class_name: "Ci::Minutes::AdditionalPack"
diff --git a/ee/app/models/system_access/microsoft_application.rb b/ee/app/models/system_access/microsoft_application.rb
index c7c2c3ab1303a881aa37688abb5d50b6608785c1..aba88c9efca8d3605c17c70120c4e7fe28fae060 100644
--- a/ee/app/models/system_access/microsoft_application.rb
+++ b/ee/app/models/system_access/microsoft_application.rb
@@ -4,7 +4,7 @@ module SystemAccess
   class MicrosoftApplication < ApplicationRecord
     belongs_to :namespace
     has_one :system_access_microsoft_graph_access_token,
-      class_name: 'SystemAccess::MicrosoftGraphAccessToken',
+      class_name: '::SystemAccess::MicrosoftGraphAccessToken',
       inverse_of: :system_access_microsoft_application,
       foreign_key: :system_access_microsoft_application_id
 
diff --git a/ee/config/feature_flags/development/microsoft_azure_group_sync.yml b/ee/config/feature_flags/development/microsoft_azure_group_sync.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9247f59cd8a660b88c82832b6b56537e474a2a74
--- /dev/null
+++ b/ee/config/feature_flags/development/microsoft_azure_group_sync.yml
@@ -0,0 +1,8 @@
+---
+name: microsoft_azure_group_sync
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125923
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/417614
+milestone: '16.2'
+type: development
+group: group::authentication and authorization
+default_enabled: false
diff --git a/ee/lib/gitlab/auth/group_saml/auth_hash.rb b/ee/lib/gitlab/auth/group_saml/auth_hash.rb
index 07952d0b1a1c8f04ec3512aa7216c12babf1ae8e..c57a7558f48051a06742822248eba324a49dddf6 100644
--- a/ee/lib/gitlab/auth/group_saml/auth_hash.rb
+++ b/ee/lib/gitlab/auth/group_saml/auth_hash.rb
@@ -12,6 +12,10 @@ def groups
           Array.wrap(get_raw('groups') || get_raw('Groups'))
         end
 
+        def azure_group_overage_claim?
+          get_raw('http://schemas.microsoft.com/claims/groups.link').present?
+        end
+
         # Access user attributes by hash.
         #
         # auth_hash.user_attributes['can_create_group']
diff --git a/ee/lib/gitlab/auth/group_saml/membership_updater.rb b/ee/lib/gitlab/auth/group_saml/membership_updater.rb
index a48438bffbf6a87ed726b73a10c87fb8e7544d14..d06ed4eb17c03651c6796115ad7f4c47b38a3450 100644
--- a/ee/lib/gitlab/auth/group_saml/membership_updater.rb
+++ b/ee/lib/gitlab/auth/group_saml/membership_updater.rb
@@ -18,6 +18,7 @@ def initialize(user, saml_provider, auth_hash)
 
         def execute
           add_default_membership
+
           enqueue_group_sync if sync_groups?
         end
 
@@ -34,13 +35,29 @@ def add_default_membership
         end
 
         def enqueue_group_sync
-          GroupSamlGroupSyncWorker.perform_async(user.id, group.id, group_link_ids)
+          return enqueue_microsoft_group_sync if microsoft_overage_sync?
+
+          enqueue_saml_group_sync if saml_group_sync?
         end
 
         def sync_groups?
-          return false unless user && group.saml_group_sync_available?
+          user && any_group_links_in_hierarchy?
+        end
 
-          any_group_links_in_hierarchy?
+        def saml_group_sync?
+          group.saml_group_sync_available?
+        end
+
+        def microsoft_overage_sync?
+          auth_hash.azure_group_overage_claim? && microsoft_group_sync_available?
+        end
+
+        def enqueue_saml_group_sync
+          GroupSamlGroupSyncWorker.perform_async(user.id, group.id, group_link_ids)
+        end
+
+        def enqueue_microsoft_group_sync
+          ::SystemAccess::GroupSamlMicrosoftGroupSyncWorker.perform_async(user.id, group.id)
         end
 
         # rubocop:disable CodeReuse/ActiveRecord
@@ -79,6 +96,12 @@ def log_audit_event(member:)
             action: :create
           ).for_member(member).security_event
         end
+
+        def microsoft_group_sync_available?
+          ::Feature.enabled?(:microsoft_azure_group_sync, group) &&
+            group.saml_group_sync_available? && group.licensed_feature_available?(:microsoft_group_sync) &&
+            group.system_access_microsoft_application.present? && group.system_access_microsoft_application.enabled?
+        end
       end
     end
   end
diff --git a/ee/spec/features/groups/saml_providers_spec.rb b/ee/spec/features/groups/saml_providers_spec.rb
index 2e8f725d427914c7003b5316833b71d19cc9aea2..6bfe9f616da5ec21cf5516555521b2db45607975 100644
--- a/ee/spec/features/groups/saml_providers_spec.rb
+++ b/ee/spec/features/groups/saml_providers_spec.rb
@@ -23,7 +23,9 @@
   end
 
   def submit
-    click_button('Save changes')
+    page.within('.saml_provider') do
+      click_button('Save changes')
+    end
   end
 
   def test_sso
diff --git a/ee/spec/lib/gitlab/auth/group_saml/auth_hash_spec.rb b/ee/spec/lib/gitlab/auth/group_saml/auth_hash_spec.rb
index e08838df651c3bbd675406d4e27e2b90d1184869..2a0e1a7725ed89d765dff41103c2ec160a8ebc23 100644
--- a/ee/spec/lib/gitlab/auth/group_saml/auth_hash_spec.rb
+++ b/ee/spec/lib/gitlab/auth/group_saml/auth_hash_spec.rb
@@ -3,7 +3,6 @@
 require 'spec_helper'
 
 RSpec.describe Gitlab::Auth::GroupSaml::AuthHash do
-  let(:raw_info_attr) { { group_attribute => %w(Developers Owners) } }
   let(:omniauth_auth_hash) do
     OmniAuth::AuthHash.new(extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) })
   end
@@ -11,6 +10,8 @@
   subject(:saml_auth_hash) { described_class.new(omniauth_auth_hash) }
 
   describe '#groups' do
+    let(:raw_info_attr) { { group_attribute => %w(Developers Owners) } }
+
     context 'with a lowercase groups attribute' do
       let(:group_attribute) { 'groups' }
 
@@ -36,6 +37,32 @@
     end
   end
 
+  describe '#azure_group_overage_claim?' do
+    context 'when the claim is not present' do
+      let(:raw_info_attr) { {} }
+
+      it 'is false' do
+        expect(saml_auth_hash.azure_group_overage_claim?).to eq(false)
+      end
+    end
+
+    context 'when the claim is present' do
+      # The value of the claim is irrelevant, but it's still included
+      # in the test response to keep tests as real-world as possible.
+      # https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles#group-overages
+      let(:raw_info_attr) do
+        {
+          'http://schemas.microsoft.com/claims/groups.link' =>
+            ['https://graph.windows.net/8c750e43/users/e631c82c/getMemberObjects']
+        }
+      end
+
+      it 'is true' do
+        expect(saml_auth_hash.azure_group_overage_claim?).to eq(true)
+      end
+    end
+  end
+
   describe 'allowed user attributes methods' do
     context 'when the attributes are presented as an array' do
       let(:raw_info_attr) { { 'can_create_group' => %w(true), 'projects_limit' => %w(20) } }
diff --git a/ee/spec/lib/gitlab/auth/group_saml/membership_updater_spec.rb b/ee/spec/lib/gitlab/auth/group_saml/membership_updater_spec.rb
index 1f91bbc0b58fa5847c1580b573943c73d027dda3..6d67d7b5c847ba45ed13969a31ab3bbb0c15bbc3 100644
--- a/ee/spec/lib/gitlab/auth/group_saml/membership_updater_spec.rb
+++ b/ee/spec/lib/gitlab/auth/group_saml/membership_updater_spec.rb
@@ -2,134 +2,273 @@
 
 require 'spec_helper'
 
-RSpec.describe Gitlab::Auth::GroupSaml::MembershipUpdater do
+RSpec.describe Gitlab::Auth::GroupSaml::MembershipUpdater, feature_category: :system_access do
   let(:user) { create(:user) }
   let(:saml_provider) { create(:saml_provider, default_membership_role: Gitlab::Access::DEVELOPER) }
   let(:group) { saml_provider.group }
-  let(:auth_hash) do
-    Gitlab::Auth::GroupSaml::AuthHash.new(
-      OmniAuth::AuthHash.new(extra: {
-        raw_info: OneLogin::RubySaml::Attributes.new('groups' => %w(Developers Owners))
-      })
-    )
-  end
 
   subject(:update_membership) { described_class.new(user, saml_provider, auth_hash).execute }
 
-  it 'adds the user to the group' do
-    subject
+  shared_examples 'not enqueueing Microsoft Group Sync worker' do
+    it 'does not enqueue Microsoft Group Sync worker' do
+      expect(::SystemAccess::GroupSamlMicrosoftGroupSyncWorker).not_to receive(:perform_async)
 
-    expect(group.users).to include(user)
+      update_membership
+    end
   end
 
-  it 'adds the member with the specified `default_membership_role`' do
-    expect(group).to receive(:add_member).with(user, Gitlab::Access::DEVELOPER).and_call_original
-
-    update_membership
+  shared_examples 'not enqueueing Group SAML Group Sync worker' do
+    it 'does not enqueue Microsoft Group Sync worker' do
+      expect(GroupSamlGroupSyncWorker).not_to receive(:perform_async)
 
-    created_member = group.members.find_by(user: user)
-    expect(created_member.access_level).to eq(Gitlab::Access::DEVELOPER)
+      update_membership
+    end
   end
 
-  it "doesn't duplicate group membership" do
-    group.add_guest(user)
+  context 'for default behavior' do
+    let_it_be(:auth_hash) { {} }
 
-    subject
+    it 'adds the user to the group' do
+      subject
 
-    expect(group.members.count).to eq 1
-  end
+      expect(group.users).to include(user)
+    end
 
-  it "doesn't overwrite existing membership level" do
-    group.add_maintainer(user)
+    it 'adds the member with the specified `default_membership_role`' do
+      expect(group).to receive(:add_member).with(user, Gitlab::Access::DEVELOPER).and_call_original
 
-    subject
+      update_membership
 
-    expect(group.members.pluck(:access_level)).to eq([Gitlab::Access::MAINTAINER])
-  end
+      created_member = group.members.find_by(user: user)
+      expect(created_member.access_level).to eq(Gitlab::Access::DEVELOPER)
+    end
+
+    it "doesn't duplicate group membership" do
+      group.add_guest(user)
 
-  it "logs an audit event" do
-    expect do
       subject
-    end.to change { AuditEvent.by_entity('Group', group).count }.by(1)
 
-    expect(AuditEvent.last.details).to include(add: 'user_access', target_details: user.name, as: 'Developer')
-  end
+      expect(group.members.count).to eq 1
+    end
 
-  it 'does not enqueue group sync' do
-    expect(GroupSamlGroupSyncWorker).not_to receive(:perform_async)
+    it "doesn't overwrite existing membership level" do
+      group.add_maintainer(user)
 
-    update_membership
-  end
+      subject
 
-  context 'when SAML group links exist' do
-    def stub_saml_group_sync_available(enabled)
-      allow(group).to receive(:saml_group_sync_available?).and_return(enabled)
+      expect(group.members.pluck(:access_level)).to eq([Gitlab::Access::MAINTAINER])
     end
 
-    let!(:group_link) { create(:saml_group_link, saml_group_name: 'Owners', group: group) }
-    let!(:subgroup_link) { create(:saml_group_link, saml_group_name: 'Developers', group: create(:group, parent: group)) }
+    it "logs an audit event" do
+      expect do
+        subject
+      end.to change { AuditEvent.by_entity('Group', group).count }.by(1)
 
-    context 'when group sync is not available' do
-      before do
-        stub_saml_group_sync_available(false)
-      end
+      expect(AuditEvent.last.details).to include(add: 'user_access', target_details: user.name, as: 'Developer')
+    end
 
-      it 'does not enqueue group sync' do
-        expect(GroupSamlGroupSyncWorker).not_to receive(:perform_async)
+    it_behaves_like 'not enqueueing Group SAML Group Sync worker'
+    it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+  end
 
-        update_membership
-      end
-    end
+  context 'when SAML group links exist' do
+    let!(:group_link) { create(:saml_group_link, saml_group_name: 'Owners', group: group) }
+    let!(:subgroup_link) { create(:saml_group_link, saml_group_name: 'Developers', group: create(:group, parent: group)) }
 
-    context 'when group sync is available' do
-      before do
-        stub_saml_group_sync_available(true)
+    context 'when the auth hash contains groups' do
+      let_it_be(:auth_hash) do
+        Gitlab::Auth::GroupSaml::AuthHash.new(
+          OmniAuth::AuthHash.new(extra: {
+            raw_info: OneLogin::RubySaml::Attributes.new('groups' => %w(Developers Owners))
+          })
+        )
       end
 
-      it 'enqueues group sync' do
-        expect(GroupSamlGroupSyncWorker).to receive(:perform_async).with(user.id, group.id, match_array([group_link.id, subgroup_link.id]))
+      context 'when group sync is not available' do
+        before do
+          stub_saml_group_sync_available(false)
+        end
 
-        update_membership
+        it_behaves_like 'not enqueueing Group SAML Group Sync worker'
       end
 
-      context 'with a group link outside the top-level group' do
+      context 'when group sync is available' do
         before do
-          create(:saml_group_link, saml_group_name: 'Developers', group: create(:group))
+          stub_saml_group_sync_available(true)
         end
 
-        it 'enqueues group sync without the outside group' do
-          expect(GroupSamlGroupSyncWorker).to receive(:perform_async).with(user.id, group.id, match_array([group_link.id, subgroup_link.id]))
+        it 'enqueues group sync' do
+          expect(GroupSamlGroupSyncWorker)
+            .to receive(:perform_async).with(user.id, group.id, match_array([group_link.id, subgroup_link.id]))
 
           update_membership
         end
-      end
 
-      context 'when auth hash contains no groups' do
-        let!(:auth_hash) do
-          Gitlab::Auth::GroupSaml::AuthHash.new(
-            OmniAuth::AuthHash.new(extra: { raw_info: OneLogin::RubySaml::Attributes.new })
-          )
+        context 'with a group link outside the top-level group' do
+          before do
+            create(:saml_group_link, saml_group_name: 'Developers', group: create(:group))
+          end
+
+          it 'enqueues group sync without the outside group' do
+            expect(GroupSamlGroupSyncWorker)
+              .to receive(:perform_async).with(user.id, group.id, match_array([group_link.id, subgroup_link.id]))
+
+            update_membership
+          end
         end
 
-        it 'enqueues group sync' do
-          expect(GroupSamlGroupSyncWorker).to receive(:perform_async).with(user.id, group.id, [])
+        context 'when auth hash contains no groups' do
+          let!(:auth_hash) do
+            Gitlab::Auth::GroupSaml::AuthHash.new(
+              OmniAuth::AuthHash.new(extra: { raw_info: OneLogin::RubySaml::Attributes.new })
+            )
+          end
 
-          update_membership
+          it 'enqueues group sync' do
+            expect(GroupSamlGroupSyncWorker).to receive(:perform_async).with(user.id, group.id, [])
+
+            update_membership
+          end
+        end
+
+        context 'when auth hash groups do not match group links' do
+          before do
+            group_link.update!(saml_group_name: 'Web Developers')
+            subgroup_link.destroy!
+          end
+
+          it 'enqueues group sync' do
+            expect(GroupSamlGroupSyncWorker).to receive(:perform_async).with(user.id, group.id, [])
+
+            update_membership
+          end
         end
       end
+    end
+
+    context 'when the auth hash contains a Microsoft group claim' do
+      let_it_be(:auth_hash) do
+        Gitlab::Auth::GroupSaml::AuthHash.new(
+          OmniAuth::AuthHash.new(extra: {
+            raw_info: OneLogin::RubySaml::Attributes.new({
+              'http://schemas.microsoft.com/claims/groups.link' =>
+                ['https://graph.windows.net/8c750e43/users/e631c82c/getMemberObjects']
+            })
+          })
+        )
+      end
+
+      context 'when Microsoft Group Sync is not licensed' do
+        let!(:application) { create(:system_access_microsoft_application, enabled: true, namespace: group) }
 
-      context 'when auth hash groups do not match group links' do
         before do
-          group_link.update!(saml_group_name: 'Web Developers')
-          subgroup_link.destroy!
+          stub_feature_flags(microsoft_azure_group_sync: true)
+          stub_saml_group_sync_available(true)
         end
 
-        it 'enqueues group sync' do
-          expect(GroupSamlGroupSyncWorker).to receive(:perform_async).with(user.id, group.id, [])
+        it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+      end
 
-          update_membership
+      context 'when Microsoft Group Sync is licensed' do
+        before do
+          stub_licensed_features(microsoft_group_sync: true)
+        end
+
+        it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+
+        context 'when SAML Group Sync is not available' do
+          before do
+            stub_saml_group_sync_available(false)
+          end
+
+          it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+
+          context 'when a Microsoft Application is present and enabled' do
+            let!(:application) { create(:system_access_microsoft_application, enabled: true, namespace: group) }
+
+            it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+          end
+        end
+
+        context 'when Group SAML Group Sync is enabled' do
+          before do
+            stub_saml_group_sync_available(true)
+          end
+
+          it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+
+          context 'when a Microsoft Application is present' do
+            let!(:application) { create(:system_access_microsoft_application, namespace: group) }
+
+            context 'when the Microsoft Application is not enabled' do
+              before do
+                application.update!(enabled: false)
+              end
+
+              it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+            end
+
+            context 'when the Microsoft application is enabled' do
+              before do
+                application.update!(enabled: true)
+              end
+
+              it 'enqueues Microsoft Group Sync worker' do
+                expect(::SystemAccess::GroupSamlMicrosoftGroupSyncWorker)
+                  .to receive(:perform_async).with(user.id, group.id)
+
+                update_membership
+              end
+
+              it_behaves_like 'not enqueueing Group SAML Group Sync worker'
+
+              context 'when microsoft_azure_group_sync feature flag is not enabled' do
+                before do
+                  stub_feature_flags(microsoft_azure_group_sync: false)
+                end
+
+                it_behaves_like 'not enqueueing Microsoft Group Sync worker'
+              end
+            end
+          end
         end
       end
     end
+
+    # Microsoft should never send both, but it's important we're only running
+    # one sync. This test serves to ensure we have that safeguard in place.
+    context 'when the auth hash contains both groups and a group claim' do
+      let_it_be(:auth_hash) do
+        Gitlab::Auth::GroupSaml::AuthHash.new(
+          OmniAuth::AuthHash.new(extra: {
+            raw_info: OneLogin::RubySaml::Attributes.new({
+              'groups' => %w(Developers Owners),
+              'http://schemas.microsoft.com/claims/groups.link' =>
+                ['https://graph.windows.net/8c750e43/users/e631c82c/getMemberObjects']
+            })
+          })
+        )
+      end
+
+      let!(:application) { create(:system_access_microsoft_application, enabled: true, namespace: group) }
+
+      before do
+        stub_licensed_features(microsoft_group_sync: true)
+        stub_saml_group_sync_available(true)
+      end
+
+      it 'enqueues Microsoft Group Sync worker' do
+        expect(::SystemAccess::GroupSamlMicrosoftGroupSyncWorker)
+          .to receive(:perform_async).with(user.id, group.id)
+
+        update_membership
+      end
+
+      it_behaves_like 'not enqueueing Group SAML Group Sync worker'
+    end
+  end
+
+  def stub_saml_group_sync_available(enabled)
+    allow(group).to receive(:saml_group_sync_available?).and_return(enabled)
   end
 end