diff --git a/app/graphql/resolvers/work_item_references_resolver.rb b/app/graphql/resolvers/work_item_references_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4aa071519db6f763adfd6150a8c585168367e88f
--- /dev/null
+++ b/app/graphql/resolvers/work_item_references_resolver.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Resolvers
+  class WorkItemReferencesResolver < BaseResolver
+    prepend ::WorkItems::LookAheadPreloads
+    include Gitlab::Graphql::Authorize::AuthorizeResource
+
+    REFERENCES_LIMIT = 10
+
+    authorize :read_work_item
+
+    type ::Types::WorkItemType.connection_type, null: true
+
+    argument :context_namespace_path, GraphQL::Types::ID,
+      required: false,
+      description: 'Full path of the context namespace (project or group).'
+
+    argument :refs, [GraphQL::Types::String], required: true,
+      description: 'Work item references. Can be either a short reference or URL.'
+
+    def ready?(**args)
+      if args[:refs].size > REFERENCES_LIMIT
+        raise Gitlab::Graphql::Errors::ArgumentError,
+          format(
+            _('Number of references exceeds the limit. ' \
+              'Please provide no more than %{refs_limit} references at the same time.'),
+            refs_limit: REFERENCES_LIMIT
+          )
+      end
+
+      super
+    end
+
+    def resolve_with_lookahead(context_namespace_path: nil, refs: [])
+      return WorkItem.none if refs.empty?
+
+      @container = authorized_find!(context_namespace_path)
+      # Only ::Project is supported at the moment, future iterations will include ::Group.
+      # See https://gitlab.com/gitlab-org/gitlab/-/issues/432555
+      return WorkItem.none if container.is_a?(::Group)
+
+      apply_lookahead(find_work_items(refs))
+    end
+
+    private
+
+    attr_reader :container
+
+    # rubocop: disable CodeReuse/ActiveRecord -- #references is not an ActiveRecord method
+    def find_work_items(references)
+      links, short_references = references.partition { |r| r.include?('/work_items/') }
+
+      item_ids = references_extractor(short_references).references(:issue, ids_only: true)
+      item_ids << references_extractor(links).references(:work_item, ids_only: true) if links.any?
+
+      WorkItem.id_in(item_ids.flatten)
+    end
+    # rubocop: enable CodeReuse/ActiveRecord
+
+    def references_extractor(refs)
+      extractor = ::Gitlab::ReferenceExtractor.new(container, context[:current_user])
+      extractor.analyze(refs.join(' '), {})
+
+      extractor
+    end
+
+    def find_object(full_path)
+      Routable.find_by_full_path(full_path)
+    end
+  end
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 9c64e056f87826ea4e830c29f2480c7d7d566c29..0e39ff2c030909c6585063b7fd3bd2225e978bc6 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -216,6 +216,13 @@ class QueryType < ::Types::BaseObject
           description: 'Find machine learning models.',
           resolver: Resolvers::Ml::ModelDetailResolver
 
+    field :work_items_by_reference,
+          null: true,
+          alpha: { milestone: '16.7' },
+          description: 'Find work items by their reference.',
+          extras: [:lookahead],
+          resolver: Resolvers::WorkItemReferencesResolver
+
     def design_management
       DesignManagementObject.new(nil)
     end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index a8dc50834d46698362258e8746fa1c252a3c049a..757bc559c29df4afef4c70bcc554157621c780f9 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1033,6 +1033,27 @@ Returns [`WorkItem`](#workitem).
 | ---- | ---- | ----------- |
 | <a id="queryworkitemid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
 
+### `Query.workItemsByReference`
+
+Find work items by their reference.
+
+WARNING:
+**Introduced** in 16.7.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Returns [`WorkItemConnection`](#workitemconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="queryworkitemsbyreferencecontextnamespacepath"></a>`contextNamespacePath` | [`ID`](#id) | Full path of the context namespace (project or group). |
+| <a id="queryworkitemsbyreferencerefs"></a>`refs` | [`[String!]!`](#string) | Work item references. Can be either a short reference or URL. |
+
 ### `Query.workspace`
 
 Find a workspace.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3254a800c53694d34166e7c2b5079a93a3532dd3..af4930a341b82442c0a49892413459bb5c6b6b7d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -32845,6 +32845,9 @@ msgstr ""
 msgid "Number of files touched"
 msgstr ""
 
+msgid "Number of references exceeds the limit. Please provide no more than %{refs_limit} references at the same time."
+msgstr ""
+
 msgid "Number of replicas"
 msgstr ""
 
diff --git a/rubocop/cop/graphql/id_type.rb b/rubocop/cop/graphql/id_type.rb
index 53d79751fa8e6cc50a5dff5532d663be720cc298..deed68da0b33e912009be0c094565780a3fb841c 100644
--- a/rubocop/cop/graphql/id_type.rb
+++ b/rubocop/cop/graphql/id_type.rb
@@ -6,7 +6,10 @@ module Graphql
       class IDType < RuboCop::Cop::Base
         MSG = 'Do not use GraphQL::Types::ID, use a specific GlobalIDType instead'
 
-        ALLOWLISTED_ARGUMENTS = %i[iid full_path project_path group_path target_project_path namespace_path].freeze
+        ALLOWLISTED_ARGUMENTS = %i[
+          iid full_path project_path group_path target_project_path namespace_path
+          context_namespace_path
+        ].freeze
 
         def_node_search :graphql_id_type?, <<~PATTERN
           (send nil? :argument (_ #does_not_match?) (const (const (const nil? :GraphQL) :Types) :ID) ...)
diff --git a/spec/requests/api/graphql/work_items_by_reference_spec.rb b/spec/requests/api/graphql/work_items_by_reference_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ad2303a81e7a5f453bad6f566c461322f558bca9
--- /dev/null
+++ b/spec/requests/api/graphql/work_items_by_reference_spec.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'find work items by reference', feature_category: :portfolio_management do
+  include GraphqlHelpers
+
+  let_it_be(:current_user) { create(:user) }
+  let_it_be(:project) { create(:project, :repository, :public) }
+  let_it_be(:group2) { create(:group, :public) }
+  let_it_be(:project2) { create(:project, :repository, :public, group: group2) }
+  let_it_be(:private_project2) { create(:project, :repository, :private, group: group2) }
+  let_it_be(:work_item) { create(:work_item, :task, project: project2) }
+  let_it_be(:private_work_item) { create(:work_item, :task, project: private_project2) }
+
+  let(:references) { [work_item.to_reference(full: true), private_work_item.to_reference(full: true)] }
+
+  shared_examples 'response with matching work items' do
+    it 'returns accessible work item' do
+      post_graphql(query, current_user: current_user)
+
+      expected_items = items.map { |item| a_graphql_entity_for(item) }
+      expect(graphql_data_at('workItemsByReference', 'nodes')).to match(expected_items)
+    end
+  end
+
+  context 'when user has access only to public work items' do
+    it_behaves_like 'a working graphql query that returns data' do
+      before do
+        post_graphql(query, current_user: current_user)
+      end
+    end
+
+    it_behaves_like 'response with matching work items' do
+      let(:items) { [work_item] }
+    end
+
+    it 'avoids N+1 queries', :use_sql_query_cache do
+      post_graphql(query, current_user: current_user) # warm up
+
+      control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+        post_graphql(query, current_user: current_user)
+      end
+      expect(graphql_data_at('workItemsByReference', 'nodes').size).to eq(1)
+
+      extra_work_items = create_list(:work_item, 2, :task, project: project2)
+      refs = references + extra_work_items.map { |item| item.to_reference(full: true) }
+
+      expect do
+        post_graphql(query(refs: refs), current_user: current_user)
+      end.not_to exceed_all_query_limit(control_count)
+      expect(graphql_data_at('workItemsByReference', 'nodes').size).to eq(3)
+    end
+  end
+
+  context 'when user has access to work items in private project' do
+    before_all do
+      private_project2.add_guest(current_user)
+    end
+
+    it_behaves_like 'response with matching work items' do
+      let(:items) { [private_work_item, work_item] }
+    end
+  end
+
+  context 'when refs includes links' do
+    let_it_be(:work_item_with_url) { create(:work_item, :task, project: project2) }
+    let(:references) { [work_item.to_reference(full: true), Gitlab::UrlBuilder.build(work_item_with_url)] }
+
+    it_behaves_like 'response with matching work items' do
+      let(:items) { [work_item_with_url, work_item] }
+    end
+  end
+
+  context 'when refs includes a short reference present in the context project' do
+    let_it_be(:same_project_work_item) { create(:work_item, :task, project: project) }
+    let(:references) { ["##{same_project_work_item.iid}"] }
+
+    it_behaves_like 'response with matching work items' do
+      let(:items) { [same_project_work_item] }
+    end
+  end
+
+  context 'when user cannot access context namespace' do
+    it 'returns error' do
+      post_graphql(query(namespace_path: private_project2.full_path), current_user: current_user)
+
+      expect(graphql_data_at('workItemsByReference')).to be_nil
+      expect(graphql_errors).to contain_exactly(a_hash_including(
+        'message' => a_string_including("you don't have permission to perform this action"),
+        'path' => %w[workItemsByReference]
+      ))
+    end
+  end
+
+  context 'when the context is a group' do
+    it 'returns empty result' do
+      group2.add_guest(current_user)
+      post_graphql(query(namespace_path: group2.full_path), current_user: current_user)
+
+      expect_graphql_errors_to_be_empty
+      expect(graphql_data_at('workItemsByReference', 'nodes')).to be_empty
+    end
+  end
+
+  context 'when there are more than the max allowed references' do
+    let(:references_limit) { ::Resolvers::WorkItemReferencesResolver::REFERENCES_LIMIT }
+    let(:references) { (0..references_limit).map { |n| "##{n}" } }
+    let(:error_msg) do
+      "Number of references exceeds the limit. " \
+        "Please provide no more than #{references_limit} references at the same time."
+    end
+
+    it 'returns an error message' do
+      post_graphql(query, current_user: current_user)
+
+      expect_graphql_errors_to_include(error_msg)
+    end
+  end
+
+  def query(namespace_path: project.full_path, refs: references)
+    fields = <<~GRAPHQL
+      nodes {
+        #{all_graphql_fields_for('WorkItem', max_depth: 2)}
+      }
+    GRAPHQL
+
+    graphql_query_for('workItemsByReference', { contextNamespacePath: namespace_path, refs: refs }, fields)
+  end
+end
diff --git a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
index 257ccc553fe6f09cdcb6eefcd124c41ba605192b..6ab41d87f442b827fbd1283ddb88174b752d5650 100644
--- a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
+++ b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb
@@ -43,6 +43,7 @@
       :user,
       :users,
       :work_item,
+      :work_items_by_reference,
       :audit_event_definitions,
       :abuse_report,
       :abuse_report_labels