diff --git a/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java b/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java
index a518ee74a797e..6eb8abe7dcdca 100644
--- a/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java
+++ b/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java
@@ -107,6 +107,7 @@ public class SearchExecutionContext extends QueryRewriteContext {
     private NestedScope nestedScope;
     private final ValuesSourceRegistry valuesSourceRegistry;
     private final Map<String, MappedFieldType> runtimeMappings;
+    private Predicate<String> allowedFields;
 
     /**
      * Build a {@linkplain SearchExecutionContext}.
@@ -154,7 +155,8 @@ public SearchExecutionContext(
             ),
             allowExpensiveQueries,
             valuesSourceRegistry,
-            parseRuntimeMappings(runtimeMappings, mapperService)
+            parseRuntimeMappings(runtimeMappings, mapperService),
+            null
         );
     }
 
@@ -177,7 +179,8 @@ public SearchExecutionContext(SearchExecutionContext source) {
             source.fullyQualifiedIndex,
             source.allowExpensiveQueries,
             source.valuesSourceRegistry,
-            source.runtimeMappings
+            source.runtimeMappings,
+            source.allowedFields
         );
     }
 
@@ -199,7 +202,8 @@ private SearchExecutionContext(int shardId,
                                    Index fullyQualifiedIndex,
                                    BooleanSupplier allowExpensiveQueries,
                                    ValuesSourceRegistry valuesSourceRegistry,
-                                   Map<String, MappedFieldType> runtimeMappings) {
+                                   Map<String, MappedFieldType> runtimeMappings,
+                                   Predicate<String> allowedFields) {
         super(xContentRegistry, namedWriteableRegistry, client, nowInMillis);
         this.shardId = shardId;
         this.shardRequestIndex = shardRequestIndex;
@@ -218,6 +222,7 @@ private SearchExecutionContext(int shardId,
         this.allowExpensiveQueries = allowExpensiveQueries;
         this.valuesSourceRegistry = valuesSourceRegistry;
         this.runtimeMappings = runtimeMappings;
+        this.allowedFields = allowedFields;
     }
 
     private void reset() {
@@ -352,6 +357,10 @@ public boolean isFieldMapped(String name) {
     }
 
     private MappedFieldType fieldType(String name) {
+        // If the field is not allowed, behave as if it is not mapped
+        if (allowedFields != null && false == allowedFields.test(name)) {
+            return null;
+        }
         MappedFieldType fieldType = runtimeMappings.get(name);
         return fieldType == null ? mappingLookup.getFieldType(name) : fieldType;
     }
@@ -419,6 +428,10 @@ public void setMapUnmappedFieldAsString(boolean mapUnmappedFieldAsString) {
         this.mapUnmappedFieldAsString = mapUnmappedFieldAsString;
     }
 
+    public void setAllowedFields(Predicate<String> allowedFields) {
+        this.allowedFields = allowedFields;
+    }
+
     MappedFieldType failIfFieldMappingNotFound(String name, MappedFieldType fieldMapping) {
         if (fieldMapping != null || allowUnmappedFields) {
             return fieldMapping;
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyAction.java
new file mode 100644
index 0000000000000..e32dacd5e032c
--- /dev/null
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyAction.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionType;
+
+public final class QueryApiKeyAction extends ActionType<QueryApiKeyResponse> {
+
+    public static final String NAME = "cluster:admin/xpack/security/api_key/query";
+    public static final QueryApiKeyAction INSTANCE = new QueryApiKeyAction();
+
+    private QueryApiKeyAction() {
+        super(NAME, QueryApiKeyResponse::new);
+    }
+
+}
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequest.java
new file mode 100644
index 0000000000000..e5dd5eedc9284
--- /dev/null
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.index.query.QueryBuilder;
+
+import java.io.IOException;
+
+public final class QueryApiKeyRequest extends ActionRequest {
+
+    @Nullable
+    private final QueryBuilder queryBuilder;
+    private boolean filterForCurrentUser;
+
+    public QueryApiKeyRequest() {
+        this((QueryBuilder) null);
+    }
+
+    public QueryApiKeyRequest(QueryBuilder queryBuilder) {
+        this.queryBuilder = queryBuilder;
+    }
+
+    public QueryApiKeyRequest(StreamInput in) throws IOException {
+        super(in);
+        queryBuilder = in.readOptionalNamedWriteable(QueryBuilder.class);
+    }
+
+    public QueryBuilder getQueryBuilder() {
+        return queryBuilder;
+    }
+
+    public boolean isFilterForCurrentUser() {
+        return filterForCurrentUser;
+    }
+
+    public void setFilterForCurrentUser() {
+        filterForCurrentUser = true;
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        return null;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeOptionalNamedWriteable(queryBuilder);
+    }
+}
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponse.java
new file mode 100644
index 0000000000000..0ab1cc2e1f210
--- /dev/null
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponse.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.core.security.action.ApiKey;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Objects;
+
+/**
+ * Response for search API keys.<br>
+ * The result contains information about the API keys that were found.
+ */
+public final class QueryApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable {
+
+    private final ApiKey[] foundApiKeysInfo;
+
+    public QueryApiKeyResponse(StreamInput in) throws IOException {
+        super(in);
+        this.foundApiKeysInfo = in.readArray(ApiKey::new, ApiKey[]::new);
+    }
+
+    public QueryApiKeyResponse(Collection<ApiKey> foundApiKeysInfo) {
+        Objects.requireNonNull(foundApiKeysInfo, "found_api_keys_info must be provided");
+        this.foundApiKeysInfo = foundApiKeysInfo.toArray(new ApiKey[0]);
+    }
+
+    public static QueryApiKeyResponse emptyResponse() {
+        return new QueryApiKeyResponse(Collections.emptyList());
+    }
+
+    public ApiKey[] getApiKeyInfos() {
+        return foundApiKeysInfo;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject()
+            .array("api_keys", (Object[]) foundApiKeysInfo);
+        return builder.endObject();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeArray(foundApiKeysInfo);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o)
+            return true;
+        if (o == null || getClass() != o.getClass())
+            return false;
+        QueryApiKeyResponse that = (QueryApiKeyResponse) o;
+        return Arrays.equals(foundApiKeysInfo, that.foundApiKeysInfo);
+    }
+
+    @Override
+    public int hashCode() {
+        return Arrays.hashCode(foundApiKeysInfo);
+    }
+
+    @Override
+    public String toString() {
+        return "QueryApiKeyResponse [foundApiKeysInfo=" + foundApiKeysInfo + "]";
+    }
+
+}
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java
index 355f4dbaa732d..e9a60ed2508e3 100644
--- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java
@@ -216,7 +216,6 @@ private Builder(RoleDescriptor rd, @Nullable FieldPermissionsCache fieldPermissi
 
         public Builder cluster(Set<String> privilegeNames, Iterable<ConfigurableClusterPrivilege> configurableClusterPrivileges) {
             ClusterPermission.Builder builder = ClusterPermission.builder();
-            List<ClusterPermission> clusterPermissions = new ArrayList<>();
             if (privilegeNames.isEmpty() == false) {
                 for (String name : privilegeNames) {
                     builder = ClusterPrivilegeResolver.resolve(name).buildPermission(builder);
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java
index 22c94d6c30323..5df9ed4e60031 100644
--- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java
@@ -12,6 +12,7 @@
 import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType;
 import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
@@ -75,6 +76,9 @@ protected boolean extendedCheck(String action, TransportRequest request, Authent
                         invalidateApiKeyRequest.getUserName(), invalidateApiKeyRequest.getRealmName(),
                         invalidateApiKeyRequest.ownedByAuthenticatedUser()));
                 }
+            } else if (request instanceof QueryApiKeyRequest) {
+                final QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
+                return queryApiKeyRequest.isFilterForCurrentUser();
             }
             throw new IllegalArgumentException(
                 "manage own api key privilege only supports API key requests (not " + request.getClass().getName() + ")");
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequestTests.java
new file mode 100644
index 0000000000000..8c799631737e0
--- /dev/null
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequestTests.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.InputStreamStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.List;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+public class QueryApiKeyRequestTests extends ESTestCase {
+
+    @Override
+    protected NamedWriteableRegistry writableRegistry() {
+        final SearchModule searchModule = new SearchModule(Settings.EMPTY, List.of());
+        return new NamedWriteableRegistry(searchModule.getNamedWriteables());
+    }
+
+    public void testReadWrite() throws IOException {
+        final QueryApiKeyRequest request1 = new QueryApiKeyRequest();
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            request1.writeTo(out);
+            try (StreamInput in = new InputStreamStreamInput(new ByteArrayInputStream(out.bytes().array()))) {
+                assertThat(new QueryApiKeyRequest(in).getQueryBuilder(), nullValue());
+            }
+        }
+
+        final BoolQueryBuilder boolQueryBuilder2 = QueryBuilders.boolQuery()
+            .filter(QueryBuilders.termQuery("foo", "bar"))
+            .should(QueryBuilders.idsQuery().addIds("id1", "id2"))
+            .must(QueryBuilders.wildcardQuery("a.b", "t*y"))
+            .mustNot(QueryBuilders.prefixQuery("value", "prod"));
+        final QueryApiKeyRequest request2 = new QueryApiKeyRequest(boolQueryBuilder2);
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            request2.writeTo(out);
+            try (StreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), writableRegistry())) {
+                final QueryApiKeyRequest deserialized = new QueryApiKeyRequest(in);
+                assertThat(deserialized.getQueryBuilder().getClass(), is(BoolQueryBuilder.class));
+                assertThat((BoolQueryBuilder) deserialized.getQueryBuilder(), equalTo(boolQueryBuilder2));
+            }
+        }
+    }
+}
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java
new file mode 100644
index 0000000000000..87681c8e74afd
--- /dev/null
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.core.security.action.ApiKey;
+import org.elasticsearch.xpack.core.security.action.ApiKeyTests;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class QueryApiKeyResponseTests extends AbstractWireSerializingTestCase<QueryApiKeyResponse> {
+
+    @Override
+    protected Writeable.Reader<QueryApiKeyResponse> instanceReader() {
+        return QueryApiKeyResponse::new;
+    }
+
+    @Override
+    protected QueryApiKeyResponse createTestInstance() {
+        final List<ApiKey> apiKeys = randomList(0, 3, this::randomApiKeyInfo);
+        return new QueryApiKeyResponse(apiKeys);
+    }
+
+    @Override
+    protected QueryApiKeyResponse mutateInstance(QueryApiKeyResponse instance) throws IOException {
+        final ArrayList<ApiKey> apiKeyInfos =
+            Arrays.stream(instance.getApiKeyInfos()).collect(Collectors.toCollection(ArrayList::new));
+        switch (randomIntBetween(0, 2)) {
+            case 0:
+                apiKeyInfos.add(randomApiKeyInfo());
+                return new QueryApiKeyResponse(apiKeyInfos);
+            case 1:
+                if (false == apiKeyInfos.isEmpty()) {
+                    return new QueryApiKeyResponse(apiKeyInfos.subList(1, apiKeyInfos.size()));
+                } else {
+                    apiKeyInfos.add(randomApiKeyInfo());
+                    return new QueryApiKeyResponse(apiKeyInfos);
+                }
+            default:
+                if (false == apiKeyInfos.isEmpty()) {
+                    final int index = randomIntBetween(0, apiKeyInfos.size() - 1);
+                    apiKeyInfos.set(index, randomApiKeyInfo());
+                } else {
+                    apiKeyInfos.add(randomApiKeyInfo());
+                }
+                return new QueryApiKeyResponse(apiKeyInfos);
+        }
+    }
+
+    private ApiKey randomApiKeyInfo() {
+        final String name = randomAlphaOfLengthBetween(3, 8);
+        final String id = randomAlphaOfLength(22);
+        final String username = randomAlphaOfLengthBetween(3, 8);
+        final String realm_name = randomAlphaOfLengthBetween(3, 8);
+        final Instant creation = Instant.ofEpochMilli(randomMillisUpToYear9999());
+        final Instant expiration = randomBoolean() ? Instant.ofEpochMilli(randomMillisUpToYear9999()) : null;
+        final Map<String, Object> metadata = ApiKeyTests.randomMetadata();
+        return new ApiKey(name, id, creation, expiration, false, username, realm_name, metadata);
+    }
+}
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java
index 7a8cf46514e1f..1e102f8b49413 100644
--- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java
@@ -52,7 +52,7 @@ public void testCanAccessResourcesOf() {
         checkCanAccessResources(randomAuthentication(user1, realm1), randomAuthentication(user1, realm1));
 
         // Different username is different no matter which realm it is from
-        final User user2 = randomValueOtherThanMany(u -> u.principal().equals(user1.principal()), this::randomUser);
+        final User user2 = randomValueOtherThanMany(u -> u.principal().equals(user1.principal()), AuthenticationTests::randomUser);
         // user 2 can be from either the same realm or a different realm
         final RealmRef realm2 = randomFrom(realm1, randomRealm());
         assertCannotAccessResources(randomAuthentication(user1, realm2), randomAuthentication(user2, realm2));
@@ -136,12 +136,12 @@ private void assertCannotAccessResources(Authentication authentication0, Authent
         assertFalse(authentication1.canAccessResourcesOf(authentication0));
     }
 
-    private User randomUser() {
+    public static User randomUser() {
         return new User(randomAlphaOfLengthBetween(3, 8),
             randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)));
     }
 
-    private RealmRef randomRealm() {
+    public static RealmRef randomRealm() {
         return new RealmRef(
             randomAlphaOfLengthBetween(3, 8),
             randomFrom(FileRealmSettings.TYPE, NativeRealmSettings.TYPE, randomAlphaOfLengthBetween(3, 8)),
@@ -155,10 +155,9 @@ private RealmRef mutateRealm(RealmRef original, String name, String type) {
             randomBoolean() ? original.getNodeName() : randomAlphaOfLengthBetween(3, 8));
     }
 
-    private Authentication randomAuthentication(User user, RealmRef realmRef) {
+    public static Authentication randomAuthentication(User user, RealmRef realmRef) {
         if (user == null) {
-            user = new User(randomAlphaOfLengthBetween(3, 8),
-                randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)));
+            user = randomUser();
         }
         if (realmRef == null) {
             realmRef = randomRealm();
@@ -181,7 +180,7 @@ private Authentication randomAuthentication(User user, RealmRef realmRef) {
         }
     }
 
-    private Authentication randomApiKeyAuthentication(User user, String apiKeyId) {
+    public static Authentication randomApiKeyAuthentication(User user, String apiKeyId) {
         final RealmRef apiKeyRealm = new RealmRef("_es_api_key", "_es_api_key", randomAlphaOfLengthBetween(3, 8));
         return new Authentication(user,
             apiKeyRealm,
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java
index 30f4c860d8f9d..e5afe013e6b7c 100644
--- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java
@@ -13,6 +13,8 @@
 import org.elasticsearch.transport.TransportRequest;
 import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType;
 import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
@@ -20,6 +22,7 @@
 
 import java.util.Map;
 
+import static org.hamcrest.Matchers.is;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -115,6 +118,19 @@ public void testGetAndInvalidateApiKeyWillRespectRunAsUser() {
             InvalidateApiKeyRequest.usingRealmAndUserName("realm_b", "user_b"), authentication));
     }
 
+    public void testCheckQueryApiKeyRequest() {
+        final ClusterPermission clusterPermission =
+            ManageOwnApiKeyClusterPrivilege.INSTANCE.buildPermission(ClusterPermission.builder()).build();
+
+        final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest();
+        if (randomBoolean()) {
+            queryApiKeyRequest.setFilterForCurrentUser();
+        }
+        assertThat(
+            clusterPermission.check(QueryApiKeyAction.NAME, queryApiKeyRequest, mock(Authentication.class)),
+            is(queryApiKeyRequest.isFilterForCurrentUser()));
+    }
+
     private Authentication createMockAuthentication(String username, String realmName,
                                                     AuthenticationType authenticationType, Map<String, Object> metadata) {
         final User user = new User(username);
diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
index 6bee9a5bc2bc0..b7036fe0aa9c6 100644
--- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
+++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
@@ -174,6 +174,7 @@ public class Constants {
         "cluster:admin/xpack/security/api_key/get",
         "cluster:admin/xpack/security/api_key/grant",
         "cluster:admin/xpack/security/api_key/invalidate",
+        "cluster:admin/xpack/security/api_key/query",
         "cluster:admin/xpack/security/cache/clear",
         "cluster:admin/xpack/security/delegate_pki",
         "cluster:admin/xpack/security/enroll/node",
diff --git a/x-pack/plugin/security/qa/security-basic/build.gradle b/x-pack/plugin/security/qa/security-basic/build.gradle
index a2114e7d6c0ba..b45b38536ca0a 100644
--- a/x-pack/plugin/security/qa/security-basic/build.gradle
+++ b/x-pack/plugin/security/qa/security-basic/build.gradle
@@ -29,4 +29,6 @@ testClusters.all {
   extraConfigFile 'roles.yml', file('src/javaRestTest/resources/roles.yml')
   user username: "admin_user", password: "admin-password"
   user username: "security_test_user", password: "security-test-password", role: "security_test_role"
+  user username: "api_key_admin", password: "security-test-password", role: "api_key_admin_role"
+  user username: "api_key_user", password: "security-test-password", role: "api_key_user_role"
 }
diff --git a/x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java b/x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java
new file mode 100644
index 0000000000000..540ddcc682b32
--- /dev/null
+++ b/x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java
@@ -0,0 +1,294 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security;
+
+import org.apache.http.HttpHeaders;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.test.XContentTestUtils;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.oneOf;
+
+public class QueryApiKeyIT extends SecurityInBasicRestTestCase {
+
+    private static final String API_KEY_ADMIN_AUTH_HEADER = "Basic YXBpX2tleV9hZG1pbjpzZWN1cml0eS10ZXN0LXBhc3N3b3Jk";
+    private static final String API_KEY_USER_AUTH_HEADER = "Basic YXBpX2tleV91c2VyOnNlY3VyaXR5LXRlc3QtcGFzc3dvcmQ=";
+    private static final String TEST_USER_AUTH_HEADER = "Basic c2VjdXJpdHlfdGVzdF91c2VyOnNlY3VyaXR5LXRlc3QtcGFzc3dvcmQ=";
+
+    public void testQuery() throws IOException {
+        createApiKeys();
+        createUser("someone");
+
+        // Admin with manage_api_key can search for all keys
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"wildcard\": {\"name\": \"*alert*\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(2));
+                assertThat(apiKeys.get(0).get("name"), oneOf("my-org/alert-key-1", "my-alert-key-2"));
+                assertThat(apiKeys.get(1).get("name"), oneOf("my-org/alert-key-1", "my-alert-key-2"));
+            });
+
+        // An empty request body means search for all keys
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            randomBoolean() ? "" : "{\"query\":{\"match_all\":{}}}",
+            apiKeys -> assertThat(apiKeys.size(), equalTo(6)));
+
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{\"query\":{\"bool\":{\"must\":[" +
+                "{\"prefix\":{\"metadata.application\":\"fleet\"}},{\"term\":{\"metadata.environment.os\":\"Cat\"}}]}}}",
+            apiKeys -> {
+                assertThat(apiKeys, hasSize(2));
+                assertThat(
+                    apiKeys.stream().map(k -> k.get("name")).collect(Collectors.toList()),
+                    containsInAnyOrder("my-org/ingest-key-1", "my-org/management-key-1"));
+            }
+        );
+
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{\"query\":{\"terms\":{\"metadata.tags\":[\"prod\",\"east\"]}}}",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(5));
+            });
+
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{\"query\":{\"range\":{\"creation_time\":{\"lt\":\"now\"}}}}",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(6));
+            });
+
+        // Search for keys belong to an user
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"term\": {\"username\": \"api_key_user\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(2));
+                assertThat(apiKeys.stream().map(m -> m.get("name")).collect(Collectors.toSet()),
+                    equalTo(Set.of("my-ingest-key-1", "my-alert-key-2")));
+            });
+
+        // Search for keys belong to users from a realm
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"term\": {\"realm_name\": \"default_file\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(6));
+                // search using explicit IDs
+                try {
+
+                    var subset = randomSubsetOf(randomIntBetween(1,5), apiKeys);
+                    assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+                        "{ \"query\": { \"ids\": { \"values\": ["
+                            + subset.stream().map(m -> "\"" + m.get("id") + "\"").collect(Collectors.joining(",")) + "] } } }",
+                        keys -> {
+                            assertThat(keys, hasSize(subset.size()));
+                        });
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            });
+
+        // Search for fields outside of the allowlist fails
+        assertQueryError(API_KEY_ADMIN_AUTH_HEADER, 400,
+            "{ \"query\": { \"prefix\": {\"api_key_hash\": \"{PBKDF2}10000$\"} } }");
+
+        // Search for fields that are not allowed in Query DSL but used internally by the service itself
+        final String fieldName = randomFrom("doc_type", "api_key_invalidated");
+        assertQueryError(API_KEY_ADMIN_AUTH_HEADER, 400,
+            "{ \"query\": { \"term\": {\"" + fieldName + "\": \"" + randomAlphaOfLengthBetween(3, 8) + "\"} } }");
+
+        // Search for api keys won't return other entities
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"term\": {\"name\": \"someone\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys, empty());
+            });
+
+        // User with manage_own_api_key will only see its own keys
+        assertQuery(API_KEY_USER_AUTH_HEADER,
+            randomBoolean() ? "" : "{\"query\":{\"match_all\":{}}}",
+            apiKeys -> {
+            assertThat(apiKeys.size(), equalTo(2));
+            assertThat(apiKeys.stream().map(m -> m.get("name")).collect(Collectors.toSet()),
+                containsInAnyOrder("my-ingest-key-1", "my-alert-key-2"));
+        });
+
+        assertQuery(API_KEY_USER_AUTH_HEADER,
+            "{ \"query\": { \"wildcard\": {\"name\": \"*alert*\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(1));
+                assertThat(apiKeys.get(0).get("name"), equalTo("my-alert-key-2"));
+            });
+
+        // User without manage_api_key or manage_own_api_key gets 403 trying to search API keys
+        assertQueryError(TEST_USER_AUTH_HEADER, 403,
+            "{ \"query\": { \"wildcard\": {\"name\": \"*alert*\"} } }");
+    }
+
+    public void testQueryShouldRespectOwnerIdentityWithApiKeyAuth() throws IOException {
+        final Tuple<String, String> powerKey = createApiKey("power-key-1", null, null, API_KEY_ADMIN_AUTH_HEADER);
+        final String powerKeyAuthHeader = "ApiKey " + Base64.getEncoder()
+            .encodeToString((powerKey.v1() + ":" + powerKey.v2()).getBytes(StandardCharsets.UTF_8));
+
+        final Tuple<String, String> limitKey = createApiKey("limit-key-1",
+            Map.of("a", Map.of("cluster", List.of("manage_own_api_key"))), null, API_KEY_ADMIN_AUTH_HEADER);
+        final String limitKeyAuthHeader = "ApiKey " + Base64.getEncoder()
+            .encodeToString((limitKey.v1() + ":" + limitKey.v2()).getBytes(StandardCharsets.UTF_8));
+
+        createApiKey("power-key-1-derived-1", Map.of("a", Map.of()), null, powerKeyAuthHeader);
+        createApiKey("limit-key-1-derived-1", Map.of("a", Map.of()), null, limitKeyAuthHeader);
+
+        createApiKey("user-key-1", Map.of(), API_KEY_USER_AUTH_HEADER);
+        createApiKey("user-key-2", Map.of(), API_KEY_USER_AUTH_HEADER);
+
+        // powerKey gets back all keys since it has manage_api_key privilege
+        assertQuery(powerKeyAuthHeader, "", apiKeys -> {
+            assertThat(apiKeys.size(), equalTo(6));
+            assertThat(
+                apiKeys.stream().map(m -> (String) m.get("name")).collect(Collectors.toUnmodifiableSet()),
+                equalTo(Set.of("power-key-1", "limit-key-1", "power-key-1-derived-1", "limit-key-1-derived-1",
+                    "user-key-1", "user-key-2")));
+        });
+
+        // limitKey gets only keys owned by the original user, not including the derived keys since they are not
+        // owned by the user (realm_name is _es_api_key).
+        assertQuery(limitKeyAuthHeader, "", apiKeys -> {
+            assertThat(apiKeys.size(), equalTo(2));
+            assertThat(
+                apiKeys.stream().map(m -> (String) m.get("name")).collect(Collectors.toUnmodifiableSet()),
+                equalTo(Set.of("power-key-1", "limit-key-1")));
+        });
+    }
+
+    private void assertQueryError(String authHeader, int statusCode, String body) throws IOException {
+        final Request request = new Request("GET", "/_security/_query/api_key");
+        request.setJsonEntity(body);
+        request.setOptions(
+            request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final ResponseException responseException = expectThrows(ResponseException.class, () -> client().performRequest(request));
+        assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(statusCode));
+    }
+
+    private void assertQuery(String authHeader, String body,
+                             Consumer<List<Map<String, Object>>> apiKeysVerifier) throws IOException {
+        final Request request = new Request("GET", "/_security/_query/api_key");
+        request.setJsonEntity(body);
+        request.setOptions(
+            request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> responseMap = responseAsMap(response);
+        @SuppressWarnings("unchecked")
+        final List<Map<String, Object>> api_keys = (List<Map<String, Object>>) responseMap.get("api_keys");
+        apiKeysVerifier.accept(api_keys);
+    }
+
+    private void createApiKeys() throws IOException {
+        createApiKey(
+            "my-org/ingest-key-1",
+            Map.of(
+                "application", "fleet-agent",
+                "tags", List.of("prod", "east"),
+                "environment", Map.of(
+                    "os", "Cat", "level", 42, "system", false, "hostname", "my-org-host-1")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-org/ingest-key-2",
+            Map.of(
+                "application", "fleet-server",
+                "tags", List.of("staging", "east"),
+                "environment", Map.of(
+                    "os", "Dog", "level", 11, "system", true, "hostname", "my-org-host-2")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-org/management-key-1",
+            Map.of(
+                "application", "fleet-agent",
+                "tags", List.of("prod", "west"),
+                "environment", Map.of(
+                    "os", "Cat", "level", 11, "system", false, "hostname", "my-org-host-3")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-org/alert-key-1",
+            Map.of(
+                "application", "siem",
+                "tags", List.of("prod", "north", "upper"),
+                "environment", Map.of(
+                    "os", "Dog", "level", 3, "system", true, "hostname", "my-org-host-4")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-ingest-key-1",
+            Map.of(
+                "application", "cli",
+                "tags", List.of("user", "test"),
+                "notes", Map.of(
+                    "sun", "hot", "earth", "blue")
+            ),
+            API_KEY_USER_AUTH_HEADER);
+
+        createApiKey(
+            "my-alert-key-2",
+            Map.of(
+                "application", "web",
+                "tags", List.of("app", "prod"),
+                "notes", Map.of(
+                    "shared", false, "weather", "sunny")
+            ),
+            API_KEY_USER_AUTH_HEADER);
+    }
+
+    private Tuple<String, String> createApiKey(String name, Map<String, Object> metadata, String authHeader) throws IOException {
+        return createApiKey(name, null, metadata, authHeader);
+    }
+
+    private Tuple<String, String> createApiKey(String name,
+                                               Map<String, Object> roleDescriptors,
+                                               Map<String, Object> metadata,
+                                               String authHeader) throws IOException {
+        final Request request = new Request("POST", "/_security/api_key");
+        final String roleDescriptorsString =
+            XContentTestUtils.convertToXContent(roleDescriptors == null ? Map.of() : roleDescriptors, XContentType.JSON).utf8ToString();
+        final String metadataString =
+            XContentTestUtils.convertToXContent(metadata == null ? Map.of() : metadata, XContentType.JSON).utf8ToString();
+        request.setJsonEntity("{\"name\":\"" + name
+            + "\", \"role_descriptors\":" + roleDescriptorsString
+            + ", \"metadata\":" + metadataString + "}");
+        request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> m = responseAsMap(response);
+        return new Tuple<>((String) m.get("id"), (String) m.get("api_key"));
+    }
+
+    private void createUser(String name) throws IOException {
+        final Request request = new Request("POST", "/_security/user/" + name);
+        request.setJsonEntity("{\"password\":\"super-strong-password\",\"roles\":[]}");
+        assertOK(adminClient().performRequest(request));
+    }
+}
diff --git a/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml b/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml
index 9b2171257fc61..1069e3a38ebfa 100644
--- a/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml
+++ b/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml
@@ -6,3 +6,14 @@ security_test_role:
   indices:
     - names: [ "index_allowed" ]
       privileges: [ "read", "write", "create_index" ]
+
+# The admin role also has the manage_own_api_key privilege to ensure this lesser privilege will not
+# interfere with the behaviour of the greater manage_api_key privilege
+api_key_admin_role:
+  cluster:
+    - manage_own_api_key
+    - manage_api_key
+
+api_key_user_role:
+  cluster:
+    - manage_own_api_key
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
index 5306b67e76ba9..4fcdb8d0553fc 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
@@ -96,6 +96,7 @@
 import org.elasticsearch.xpack.core.security.action.GetApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction;
@@ -169,6 +170,7 @@
 import org.elasticsearch.xpack.security.action.TransportGetApiKeyAction;
 import org.elasticsearch.xpack.security.action.TransportGrantApiKeyAction;
 import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction;
+import org.elasticsearch.xpack.security.action.apikey.TransportQueryApiKeyAction;
 import org.elasticsearch.xpack.security.action.enrollment.TransportNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.action.enrollment.TransportKibanaEnrollmentAction;
 import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
@@ -256,6 +258,7 @@
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGetApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGrantApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction;
+import org.elasticsearch.xpack.security.rest.action.apikey.RestQueryApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.enrollment.RestNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.rest.action.enrollment.RestKibanaEnrollAction;
 import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
@@ -899,6 +902,7 @@ public void onIndexModule(IndexModule module) {
                 new ActionHandler<>(GrantApiKeyAction.INSTANCE, TransportGrantApiKeyAction.class),
                 new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class),
                 new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class),
+                new ActionHandler<>(QueryApiKeyAction.INSTANCE, TransportQueryApiKeyAction.class),
                 new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class),
                 new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class),
                 new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class),
@@ -968,6 +972,7 @@ public List<RestHandler> getRestHandlers(Settings settings, RestController restC
                 new RestGrantApiKeyAction(settings, getLicenseState()),
                 new RestInvalidateApiKeyAction(settings, getLicenseState()),
                 new RestGetApiKeyAction(settings, getLicenseState()),
+                new RestQueryApiKeyAction(settings, getLicenseState()),
                 new RestDelegatePkiAuthenticationAction(settings, getLicenseState()),
                 new RestCreateServiceAccountTokenAction(settings, getLicenseState()),
                 new RestDeleteServiceAccountTokenAction(settings, getLicenseState()),
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyAction.java
new file mode 100644
index 0000000000000..a4fb6d8a4bf32
--- /dev/null
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyAction.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.action.apikey;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.SecurityContext;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+import org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder;
+
+public final class TransportQueryApiKeyAction extends HandledTransportAction<QueryApiKeyRequest, QueryApiKeyResponse> {
+
+    private final ApiKeyService apiKeyService;
+    private final SecurityContext securityContext;
+
+    @Inject
+    public TransportQueryApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService,
+                                      SecurityContext context) {
+        super(QueryApiKeyAction.NAME, transportService, actionFilters, QueryApiKeyRequest::new);
+        this.apiKeyService = apiKeyService;
+        this.securityContext = context;
+    }
+
+    @Override
+    protected void doExecute(Task task, QueryApiKeyRequest request, ActionListener<QueryApiKeyResponse> listener) {
+        final Authentication authentication = securityContext.getAuthentication();
+        if (authentication == null) {
+            listener.onFailure(new IllegalStateException("authentication is required"));
+        }
+
+        final ApiKeyBoolQueryBuilder apiKeyBoolQueryBuilder =
+            ApiKeyBoolQueryBuilder.build(request.getQueryBuilder(), request.isFilterForCurrentUser() ? authentication : null);
+
+        apiKeyService.queryApiKeys(apiKeyBoolQueryBuilder, listener);
+    }
+
+}
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
index a879eb80eaaa9..a77dfbb1bf088 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
@@ -85,12 +85,14 @@
 import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
 import org.elasticsearch.xpack.core.security.authc.support.Hasher;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.user.User;
+import org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder;
 import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
 import org.elasticsearch.xpack.security.support.FeatureNotEnabledException;
 import org.elasticsearch.xpack.security.support.FeatureNotEnabledException.Feature;
@@ -1130,6 +1132,27 @@ public void getApiKeys(String realmName, String username, String apiKeyName, Str
             }, listener::onFailure));
     }
 
+    public void queryApiKeys(ApiKeyBoolQueryBuilder apiKeyBoolQueryBuilder, ActionListener<QueryApiKeyResponse> listener) {
+        ensureEnabled();
+        final ActionListener<Collection<ApiKey>> wrappedListener = ActionListener.wrap(apiKeyInfos -> {
+            if (apiKeyInfos.isEmpty()) {
+                logger.debug("No active api keys found for query [{}]", apiKeyBoolQueryBuilder);
+                listener.onResponse(QueryApiKeyResponse.emptyResponse());
+            } else {
+                listener.onResponse(new QueryApiKeyResponse(apiKeyInfos));
+            }
+        }, listener::onFailure);
+
+        final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze();
+        if (frozenSecurityIndex.indexExists() == false) {
+            wrappedListener.onResponse(Collections.emptyList());
+        } else if (frozenSecurityIndex.isAvailable() == false) {
+            wrappedListener.onFailure(frozenSecurityIndex.getUnavailableReason());
+        } else {
+            findApiKeys(apiKeyBoolQueryBuilder, true, true, wrappedListener);
+        }
+    }
+
     private RemovalListener<String, ListenableFuture<CachedApiKeyHashResult>> getAuthCacheRemovalListener(int maximumWeight) {
         return notification -> {
             if (RemovalReason.EVICTED == notification.getRemovalReason() && getApiKeyAuthCache().count() >= maximumWeight) {
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java
index c2bd7a2d1e70d..642c89e7a0c9f 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java
@@ -42,6 +42,8 @@
 import org.elasticsearch.transport.TransportRequest;
 import org.elasticsearch.xpack.core.MigrateToDataStreamAction;
 import org.elasticsearch.xpack.core.action.CreateDataStreamAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesRequest;
 import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesResponse;
 import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesRequest;
@@ -273,13 +275,25 @@ private void authorizeAction(final RequestInfo requestInfo, final String request
         final String action = requestInfo.getAction();
         final AuthorizationEngine authzEngine = getAuthorizationEngine(authentication);
         final AuditTrail auditTrail = auditTrailService.get();
+
         if (ClusterPrivilegeResolver.isClusterAction(action)) {
             final ActionListener<AuthorizationResult> clusterAuthzListener =
                 wrapPreservingContext(new AuthorizationResultListener<>(result -> {
                         threadContext.putTransient(INDICES_PERMISSIONS_KEY, IndicesAccessControl.ALLOW_ALL);
                         listener.onResponse(null);
                     }, listener::onFailure, requestInfo, requestId, authzInfo), threadContext);
-            authzEngine.authorizeClusterAction(requestInfo, authzInfo, clusterAuthzListener);
+            authzEngine.authorizeClusterAction(requestInfo, authzInfo, ActionListener.wrap(result -> {
+                if (false == result.isGranted() && QueryApiKeyAction.NAME.equals(action)) {
+                    assert request instanceof QueryApiKeyRequest : "request does not match action";
+                    final QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
+                    if (false == queryApiKeyRequest.isFilterForCurrentUser()) {
+                        queryApiKeyRequest.setFilterForCurrentUser();
+                        authzEngine.authorizeClusterAction(requestInfo, authzInfo, clusterAuthzListener);
+                        return;
+                    }
+                }
+                clusterAuthzListener.onResponse(result);
+            }, clusterAuthzListener::onFailure));
         } else if (isIndexAction(action)) {
             final Metadata metadata = clusterService.state().metadata();
             final AsyncSupplier<Set<String>> authorizedIndicesSupplier = new CachingAsyncSupplier<>(authzIndicesListener ->
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java
new file mode 100644
index 0000000000000..d7c3d5011d8c8
--- /dev/null
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.rest.action.apikey;
+
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ParseField;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+import static org.elasticsearch.index.query.AbstractQueryBuilder.parseInnerQueryBuilder;
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+
+/**
+ * Rest action to search for API keys
+ */
+public final class RestQueryApiKeyAction extends SecurityBaseRestHandler {
+
+    private static final ConstructingObjectParser<QueryApiKeyRequest, Void> PARSER = new ConstructingObjectParser<>(
+        "query_api_key_request",
+        a -> new QueryApiKeyRequest((QueryBuilder) a[0]));
+
+    static {
+        PARSER.declareObject(optionalConstructorArg(), (p, c) -> parseInnerQueryBuilder(p), new ParseField("query"));
+    }
+
+    /**
+     * @param settings the node's settings
+     * @param licenseState the license state that will be used to determine if
+     * security is licensed
+     */
+    public RestQueryApiKeyAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(
+            new Route(GET, "/_security/_query/api_key"),
+            new Route(POST, "/_security/_query/api_key"));
+    }
+
+    @Override
+    public String getName() {
+        return "xpack_security_query_api_key";
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException {
+        final QueryApiKeyRequest queryApiKeyRequest =
+            request.hasContentOrSourceParam() ? PARSER.parse(request.contentOrSourceParamParser(), null) : new QueryApiKeyRequest();
+
+        return channel -> client.execute(QueryApiKeyAction.INSTANCE, queryApiKeyRequest, new RestToXContentListener<>(channel));
+    }
+}
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java
new file mode 100644
index 0000000000000..bebefe38f9403
--- /dev/null
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.support;
+
+import org.apache.lucene.search.Query;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.IdsQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.index.query.QueryRewriteContext;
+import org.elasticsearch.index.query.RangeQueryBuilder;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.index.query.TermQueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.index.query.WildcardQueryBuilder;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+
+public class ApiKeyBoolQueryBuilder extends BoolQueryBuilder {
+
+    // Field names allowed at the index level
+    private static final Set<String> ALLOWED_EXACT_INDEX_FIELD_NAMES =
+        Set.of("doc_type", "name", "api_key_invalidated", "creation_time", "expiration_time");
+
+    private ApiKeyBoolQueryBuilder() {}
+
+    /**
+     * Build a bool query that is specialised for query API keys information from the security index.
+     * The method processes the given QueryBuilder to ensure:
+     *   * Only fields from an allowlist are queried
+     *   * Only query types from an allowlist are used
+     *   * Field names used in the Query DSL get translated into corresponding names used at the index level.
+     *     This helps decouple the user facing and implementation level changes.
+     *   * User's security context gets applied when necessary
+     *   * Not exposing any other types of documents stored in the same security index
+     *
+     * @param queryBuilder This represents the query parsed directly from the user input. It is validated
+     *                     and transformed (see above).
+     * @param authentication The user's authentication object. If present, it will be used to filter the results
+     *                       to only include API keys owned by the user.
+     * @return A specialised query builder for API keys that is safe to run on the security index.
+     */
+    public static ApiKeyBoolQueryBuilder build(QueryBuilder queryBuilder, @Nullable Authentication authentication) {
+        final ApiKeyBoolQueryBuilder finalQuery = new ApiKeyBoolQueryBuilder();
+        if (queryBuilder != null) {
+            QueryBuilder processedQuery = doProcess(queryBuilder);
+            finalQuery.must(processedQuery);
+        }
+        finalQuery.filter(QueryBuilders.termQuery("doc_type", "api_key"));
+
+        if (authentication != null) {
+            finalQuery
+                .filter(QueryBuilders.termQuery("creator.principal", authentication.getUser().principal()))
+                .filter(QueryBuilders.termQuery("creator.realm", ApiKeyService.getCreatorRealmName(authentication)));
+        }
+        return finalQuery;
+    }
+
+    private static QueryBuilder doProcess(QueryBuilder qb) {
+        if (qb instanceof BoolQueryBuilder) {
+            final BoolQueryBuilder query = (BoolQueryBuilder) qb;
+            final BoolQueryBuilder newQuery =
+                QueryBuilders.boolQuery().minimumShouldMatch(query.minimumShouldMatch()).adjustPureNegative(query.adjustPureNegative());
+            query.must().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::must);
+            query.should().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::should);
+            query.mustNot().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::mustNot);
+            query.filter().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::filter);
+            return newQuery;
+        } else if (qb instanceof MatchAllQueryBuilder) {
+            return qb;
+        } else if (qb instanceof IdsQueryBuilder) {
+            return qb;
+        } else if (qb instanceof TermQueryBuilder) {
+            final TermQueryBuilder query = (TermQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.termQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive());
+        } else if (qb instanceof TermsQueryBuilder) {
+            final TermsQueryBuilder query = (TermsQueryBuilder) qb;
+            if (query.termsLookup() != null) {
+                throw new IllegalArgumentException("terms query with terms lookup is not supported for API Key query");
+            }
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.termsQuery(translatedFieldName, query.getValues());
+        } else if (qb instanceof PrefixQueryBuilder) {
+            final PrefixQueryBuilder query = (PrefixQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.prefixQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive());
+        } else if (qb instanceof WildcardQueryBuilder) {
+            final WildcardQueryBuilder query = (WildcardQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.wildcardQuery(translatedFieldName, query.value())
+                .caseInsensitive(query.caseInsensitive())
+                .rewrite(query.rewrite());
+        } else if (qb instanceof RangeQueryBuilder) {
+            final RangeQueryBuilder query = (RangeQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            if (query.relation() != null) {
+                throw new IllegalArgumentException("range query with relation is not supported for API Key query");
+            }
+            final RangeQueryBuilder newQuery = QueryBuilders.rangeQuery(translatedFieldName);
+            if (query.format() != null) {
+                newQuery.format(query.format());
+            }
+            if (query.timeZone() != null) {
+                newQuery.timeZone(query.timeZone());
+            }
+            if (query.from() != null) {
+                newQuery.from(query.from()).includeLower(query.includeLower());
+            }
+            if (query.to() != null) {
+                newQuery.to(query.to()).includeUpper(query.includeUpper());
+            }
+            return newQuery.boost(query.boost());
+        } else {
+            throw new IllegalArgumentException("Query type [" + qb.getName() + "] is not supported for API Key query");
+        }
+    }
+
+
+    @Override
+    protected Query doToQuery(SearchExecutionContext context) throws IOException {
+        context.setAllowedFields(ApiKeyBoolQueryBuilder::isIndexFieldNameAllowed);
+        return super.doToQuery(context);
+    }
+
+    @Override
+    protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException {
+        if (queryRewriteContext instanceof SearchExecutionContext) {
+            ((SearchExecutionContext) queryRewriteContext).setAllowedFields(ApiKeyBoolQueryBuilder::isIndexFieldNameAllowed);
+        }
+        return super.doRewrite(queryRewriteContext);
+    }
+
+    static boolean isIndexFieldNameAllowed(String fieldName) {
+        return ALLOWED_EXACT_INDEX_FIELD_NAMES.contains(fieldName)
+            || fieldName.startsWith("metadata_flattened.")
+            || fieldName.startsWith("creator.");
+    }
+
+    /**
+     * A class to translate query level field names to index level field names.
+     */
+    static class FieldNameTranslators {
+        static final List<FieldNameTranslator> FIELD_NAME_TRANSLATORS;
+
+        static {
+            FIELD_NAME_TRANSLATORS = List.of(
+                new ExactFieldNameTranslator(s -> "creator.principal", "username"),
+                new ExactFieldNameTranslator(s -> "creator.realm", "realm_name"),
+                new ExactFieldNameTranslator(Function.identity(), "name"),
+                new ExactFieldNameTranslator(Function.identity(), "creation_time"),
+                new ExactFieldNameTranslator(Function.identity(), "expiration_time"),
+                new PrefixFieldNameTranslator(s -> "metadata_flattened" + s.substring(8), "metadata.")
+            );
+        }
+
+        /**
+         * Translate the query level field name to index level field names.
+         * It throws an exception if the field name is not explicitly allowed.
+         */
+        static String translate(String fieldName) {
+            for (FieldNameTranslator translator : FIELD_NAME_TRANSLATORS) {
+                if (translator.supports(fieldName)) {
+                    return translator.translate(fieldName);
+                }
+            }
+            throw new IllegalArgumentException("Field [" + fieldName + "] is not allowed for API Key query");
+        }
+
+        abstract static class FieldNameTranslator {
+
+            private final Function<String, String> translationFunc;
+
+            protected FieldNameTranslator(Function<String, String> translationFunc) {
+                this.translationFunc = translationFunc;
+            }
+
+            String translate(String fieldName) {
+                return translationFunc.apply(fieldName);
+            }
+
+            abstract boolean supports(String fieldName);
+        }
+
+        static class ExactFieldNameTranslator extends FieldNameTranslator {
+            private final String name;
+
+            ExactFieldNameTranslator(Function<String, String> translationFunc, String name) {
+                super(translationFunc);
+                this.name = name;
+            }
+
+            @Override
+            public boolean supports(String fieldName) {
+                return name.equals(fieldName);
+            }
+        }
+
+        static class PrefixFieldNameTranslator extends FieldNameTranslator {
+            private final String prefix;
+
+            PrefixFieldNameTranslator(Function<String, String> translationFunc, String prefix) {
+                super(translationFunc);
+                this.prefix = prefix;
+            }
+
+            @Override
+            boolean supports(String fieldName) {
+                return fieldName.startsWith(prefix);
+            }
+        }
+    }
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java
new file mode 100644
index 0000000000000..93bda06846806
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.rest.action.apikey;
+
+import org.apache.lucene.util.SetOnce;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.NamedXContentRegistry;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.AbstractRestChannel;
+import org.elasticsearch.rest.RestChannel;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.rest.FakeRestRequest;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
+
+import java.util.List;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class RestQueryApiKeyActionTests extends ESTestCase {
+
+    private final XPackLicenseState mockLicenseState = mock(XPackLicenseState.class);
+    private Settings settings;
+    private ThreadPool threadPool;
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        settings = Settings.builder().put("path.home", createTempDir().toString()).put("node.name", "test-" + getTestName())
+            .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build();
+        when(mockLicenseState.isSecurityEnabled()).thenReturn(true);
+        threadPool = new ThreadPool(settings);
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        terminate(threadPool);
+    }
+
+    @Override
+    protected NamedXContentRegistry xContentRegistry() {
+        final SearchModule searchModule = new SearchModule(Settings.EMPTY, List.of());
+        return new NamedXContentRegistry(searchModule.getNamedXContents());
+    }
+
+    public void testQueryParsing() throws Exception {
+        final String query1 = "{\"query\":{\"bool\":{\"must\":[{\"terms\":{\"name\":[\"k1\",\"k2\"]}}]," +
+            "\"should\":[{\"prefix\":{\"metadata.environ\":\"prod\"}}]}}}";
+        final FakeRestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry())
+            .withContent(new BytesArray(query1), XContentType.JSON)
+            .build();
+
+        final SetOnce<RestResponse> responseSetOnce = new SetOnce<>();
+        final RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) {
+            @Override
+            public void sendResponse(RestResponse restResponse) {
+                responseSetOnce.set(restResponse);
+            }
+        };
+
+        try (NodeClient client = new NodeClient(Settings.EMPTY, threadPool) {
+            @SuppressWarnings("unchecked")
+            @Override
+            public <Request extends ActionRequest, Response extends ActionResponse>
+            void doExecute(ActionType<Response> action, Request request, ActionListener<Response> listener) {
+                QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
+                final QueryBuilder queryBuilder = queryApiKeyRequest.getQueryBuilder();
+                assertNotNull(queryBuilder);
+                assertThat(queryBuilder.getClass(), is(BoolQueryBuilder.class));
+                final BoolQueryBuilder boolQueryBuilder = (BoolQueryBuilder) queryBuilder;
+                assertTrue(boolQueryBuilder.filter().isEmpty());
+                assertTrue(boolQueryBuilder.mustNot().isEmpty());
+                assertThat(boolQueryBuilder.must().size(), equalTo(1));
+                final QueryBuilder mustQueryBuilder = boolQueryBuilder.must().get(0);
+                assertThat(mustQueryBuilder.getClass(), is(TermsQueryBuilder.class));
+                assertThat(((TermsQueryBuilder) mustQueryBuilder).fieldName(), equalTo("name"));
+                assertThat(boolQueryBuilder.should().size(), equalTo(1));
+                final QueryBuilder shouldQueryBuilder = boolQueryBuilder.should().get(0);
+                assertThat(shouldQueryBuilder.getClass(), is(PrefixQueryBuilder.class));
+                assertThat(((PrefixQueryBuilder) shouldQueryBuilder).fieldName(), equalTo("metadata.environ"));
+                listener.onResponse((Response) new QueryApiKeyResponse(List.of()));
+            }
+        }) {
+            final RestQueryApiKeyAction restQueryApiKeyAction = new RestQueryApiKeyAction(Settings.EMPTY, mockLicenseState);
+            restQueryApiKeyAction.handleRequest(restRequest, restChannel, client);
+        }
+
+        assertNotNull(responseSetOnce.get());
+    }
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java
new file mode 100644
index 0000000000000..2a7120eac52f6
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.support;
+
+import org.elasticsearch.index.query.AbstractQueryBuilder;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.DistanceFeatureQueryBuilder;
+import org.elasticsearch.index.query.IdsQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.MultiTermQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.index.query.RangeQueryBuilder;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.index.query.SpanQueryBuilder;
+import org.elasticsearch.index.query.TermQueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.index.query.WildcardQueryBuilder;
+import org.elasticsearch.indices.TermsLookup;
+import org.elasticsearch.script.Script;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.AuthenticationTests;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder.FieldNameTranslators.FIELD_NAME_TRANSLATORS;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class ApiKeyBoolQueryBuilderTests extends ESTestCase {
+
+    public void testBuildFromSimpleQuery() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final QueryBuilder q1 = randomSimpleQuery(randomFrom("name", "creation_time", "expiration_time"));
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(q1, authentication);
+        assertCommonFilterQueries(apiKeyQb1, authentication);
+        final List<QueryBuilder> mustQueries = apiKeyQb1.must();
+        assertThat(mustQueries.size(), equalTo(1));
+        assertThat(mustQueries.get(0), equalTo(q1));
+        assertTrue(apiKeyQb1.should().isEmpty());
+        assertTrue(apiKeyQb1.mustNot().isEmpty());
+    }
+
+    public void testBuildFromBoolQuery() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final BoolQueryBuilder bq1 = QueryBuilders.boolQuery();
+
+        if (randomBoolean()) {
+            bq1.must(QueryBuilders.prefixQuery("name", "prod-"));
+        }
+        if (randomBoolean()) {
+            bq1.should(QueryBuilders.wildcardQuery("name", "*-east-*"));
+        }
+        if (randomBoolean()) {
+            bq1.filter(QueryBuilders.termsQuery("name",
+                randomArray(3, 8, String[]::new, () -> "prod-" + randomInt() + "-east-" + randomInt())));
+        }
+        if (randomBoolean()) {
+            bq1.mustNot(QueryBuilders.idsQuery().addIds(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(22))));
+        }
+        if (randomBoolean()) {
+            bq1.minimumShouldMatch(randomIntBetween(1, 2));
+        }
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(bq1, authentication);
+        assertCommonFilterQueries(apiKeyQb1, authentication);
+
+        assertThat(apiKeyQb1.must(), hasSize(1));
+        assertThat(apiKeyQb1.should(), empty());
+        assertThat(apiKeyQb1.mustNot(), empty());
+        assertThat(apiKeyQb1.filter(), hasItem(QueryBuilders.termQuery("doc_type", "api_key")));
+        assertThat(apiKeyQb1.must().get(0).getClass(), is(BoolQueryBuilder.class));
+        final BoolQueryBuilder processed = (BoolQueryBuilder) apiKeyQb1.must().get(0);
+        assertThat(processed.must(), equalTo(bq1.must()));
+        assertThat(processed.should(), equalTo(bq1.should()));
+        assertThat(processed.mustNot(), equalTo(bq1.mustNot()));
+        assertThat(processed.minimumShouldMatch(), equalTo(bq1.minimumShouldMatch()));
+        assertThat(processed.filter(), equalTo(bq1.filter()));
+    }
+
+    public void testFieldNameTranslation() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+
+        // metadata
+        final String metadataKey = randomAlphaOfLengthBetween(3, 8);
+        final TermQueryBuilder q1 = QueryBuilders.termQuery("metadata." + metadataKey, randomAlphaOfLengthBetween(3, 8));
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(q1, authentication);
+        assertCommonFilterQueries(apiKeyQb1, authentication);
+        assertThat(apiKeyQb1.must().get(0), equalTo(QueryBuilders.termQuery("metadata_flattened." + metadataKey, q1.value())));
+
+        // username
+        final PrefixQueryBuilder q2 = QueryBuilders.prefixQuery("username", randomAlphaOfLength(3));
+        final ApiKeyBoolQueryBuilder apiKeyQb2 = ApiKeyBoolQueryBuilder.build(q2, authentication);
+        assertCommonFilterQueries(apiKeyQb2, authentication);
+        assertThat(apiKeyQb2.must().get(0), equalTo(QueryBuilders.prefixQuery("creator.principal", q2.value())));
+
+        // realm name
+        final WildcardQueryBuilder q3 = QueryBuilders.wildcardQuery("realm_name", "*" + randomAlphaOfLength(3));
+        final ApiKeyBoolQueryBuilder apiKeyQb3 = ApiKeyBoolQueryBuilder.build(q3, authentication);
+        assertCommonFilterQueries(apiKeyQb3, authentication);
+        assertThat(apiKeyQb3.must().get(0), equalTo(QueryBuilders.wildcardQuery("creator.realm", q3.value())));
+    }
+
+    public void testAllowListOfFieldNames() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+
+        final String randomFieldName = randomValueOtherThanMany(s -> FIELD_NAME_TRANSLATORS.stream().anyMatch(t -> t.supports(s)),
+            () -> randomAlphaOfLengthBetween(3, 20));
+        final String fieldName = randomFrom(
+                        randomFieldName,
+                        "api_key_hash",
+                        "api_key_invalidated",
+                        "doc_type",
+                        "role_descriptors",
+                        "limited_by_role_descriptors",
+                        "version",
+            "creator", "creator.metadata");
+
+        final QueryBuilder q1 = randomValueOtherThanMany(
+            q -> q.getClass() == IdsQueryBuilder.class || q.getClass() == MatchAllQueryBuilder.class,
+            () -> randomSimpleQuery(fieldName));
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+
+        assertThat(e1.getMessage(), containsString("Field [" + fieldName + "] is not allowed for API Key query"));
+    }
+
+    public void testTermsLookupIsNotAllowed() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final TermsQueryBuilder q1 = QueryBuilders.termsLookupQuery("name", new TermsLookup("lookup", "1", "names"));
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+        assertThat(e1.getMessage(), containsString("terms query with terms lookup is not supported for API Key query"));
+    }
+
+    public void testRangeQueryWithRelationIsNotAllowed() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final RangeQueryBuilder q1 = QueryBuilders.rangeQuery("creation_time").relation("contains");
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+        assertThat(e1.getMessage(), containsString("range query with relation is not supported for API Key query"));
+    }
+
+    public void testDisallowedQueryTypes() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+
+        final AbstractQueryBuilder<? extends AbstractQueryBuilder<?>> q1 = randomFrom(
+            QueryBuilders.matchQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.constantScoreQuery(mock(QueryBuilder.class)),
+            QueryBuilders.existsQuery(randomAlphaOfLength(5)),
+            QueryBuilders.boostingQuery(mock(QueryBuilder.class), mock(QueryBuilder.class)),
+            QueryBuilders.queryStringQuery("q=a:42"),
+            QueryBuilders.simpleQueryStringQuery(randomAlphaOfLength(5)),
+            QueryBuilders.combinedFieldsQuery(randomAlphaOfLength(5)),
+            QueryBuilders.disMaxQuery(),
+            QueryBuilders.distanceFeatureQuery(randomAlphaOfLength(5),
+                mock(DistanceFeatureQueryBuilder.Origin.class),
+                randomAlphaOfLength(5)),
+            QueryBuilders.fieldMaskingSpanQuery(mock(SpanQueryBuilder.class), randomAlphaOfLength(5)),
+            QueryBuilders.functionScoreQuery(mock(QueryBuilder.class)),
+            QueryBuilders.fuzzyQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.wrapperQuery(randomAlphaOfLength(5)),
+            QueryBuilders.matchBoolPrefixQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.matchPhraseQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.matchPhrasePrefixQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.moreLikeThisQuery(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(5))),
+            QueryBuilders.regexpQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.spanTermQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.spanOrQuery(mock(SpanQueryBuilder.class)),
+            QueryBuilders.spanContainingQuery(mock(SpanQueryBuilder.class), mock(SpanQueryBuilder.class)),
+            QueryBuilders.spanFirstQuery(mock(SpanQueryBuilder.class), randomIntBetween(1, 3)),
+            QueryBuilders.spanMultiTermQueryBuilder(mock(MultiTermQueryBuilder.class)),
+            QueryBuilders.spanNotQuery(mock(SpanQueryBuilder.class), mock(SpanQueryBuilder.class)),
+            QueryBuilders.scriptQuery(new Script(randomAlphaOfLength(5))),
+            QueryBuilders.scriptScoreQuery(mock(QueryBuilder.class), new Script(randomAlphaOfLength(5))),
+            QueryBuilders.geoWithinQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoBoundingBoxQuery(randomAlphaOfLength(5)),
+            QueryBuilders.geoDisjointQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoDistanceQuery(randomAlphaOfLength(5)),
+            QueryBuilders.geoIntersectionQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoShapeQuery(randomAlphaOfLength(5), randomAlphaOfLength(5))
+        );
+
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+        assertThat(e1.getMessage(), containsString("Query type [" + q1.getName() + "] is not supported for API Key query"));
+    }
+
+    public void testWillSetAllowedFields() throws IOException {
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(randomSimpleQuery("name"),
+            randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null);
+
+        final SearchExecutionContext context1 = mock(SearchExecutionContext.class);
+        doAnswer(invocationOnMock -> {
+            final Object[] args = invocationOnMock.getArguments();
+            @SuppressWarnings("unchecked")
+            final Predicate<String> predicate = (Predicate<String>) args[0];
+            assertTrue(predicate.getClass().getName().startsWith(ApiKeyBoolQueryBuilder.class.getName()));
+            testAllowedIndexFieldName(predicate);
+            return null;
+        }).when(context1).setAllowedFields(any());
+        try {
+            if (randomBoolean()) {
+                apiKeyQb1.doToQuery(context1);
+            } else {
+                apiKeyQb1.doRewrite(context1);
+            }
+        } catch (Exception e) {
+            // just ignore any exception from superclass since we only need verify the allowedFields are set
+        } finally {
+            verify(context1).setAllowedFields(any());
+        }
+    }
+
+    private void testAllowedIndexFieldName(Predicate<String> predicate) {
+        final String allowedField = randomFrom(
+            "doc_type",
+            "name",
+            "api_key_invalidated",
+            "creation_time",
+            "expiration_time",
+            "metadata_flattened." + randomAlphaOfLengthBetween(1, 10),
+            "creator." + randomAlphaOfLengthBetween(1, 10));
+        assertTrue(predicate.test(allowedField));
+
+        final String disallowedField = randomBoolean() ? (randomAlphaOfLengthBetween(1, 3) + allowedField) : (allowedField.substring(1));
+        assertFalse(predicate.test(disallowedField));
+    }
+
+    private void assertCommonFilterQueries(ApiKeyBoolQueryBuilder qb, Authentication authentication) {
+        final List<TermQueryBuilder> tqb = qb.filter()
+            .stream()
+            .filter(q -> q.getClass() == TermQueryBuilder.class)
+            .map(q -> (TermQueryBuilder) q)
+            .collect(Collectors.toUnmodifiableList());
+        assertTrue(tqb.stream().anyMatch(q -> q.equals(QueryBuilders.termQuery("doc_type", "api_key"))));
+        if (authentication == null) {
+            return;
+        }
+        assertTrue(tqb.stream()
+            .anyMatch(q -> q.equals(QueryBuilders.termQuery("creator.principal", authentication.getUser().principal()))));
+        assertTrue(tqb.stream()
+            .anyMatch(q -> q.equals(QueryBuilders.termQuery("creator.realm", ApiKeyService.getCreatorRealmName(authentication)))));
+    }
+
+    private QueryBuilder randomSimpleQuery(String name) {
+        switch (randomIntBetween(0, 6)) {
+            case 0:
+                return QueryBuilders.termQuery(name, randomAlphaOfLengthBetween(3, 8));
+            case 1:
+                return QueryBuilders.termsQuery(name, randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)));
+            case 2:
+                return QueryBuilders.idsQuery().addIds(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(22)));
+            case 3:
+                return QueryBuilders.prefixQuery(name, "prod-");
+            case 4:
+                return QueryBuilders.wildcardQuery(name, "prod-*-east-*");
+            case 5:
+                return QueryBuilders.matchAllQuery();
+            default:
+                return QueryBuilders.rangeQuery(name)
+                    .from(Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli(), randomBoolean())
+                    .to(Instant.now().toEpochMilli(), randomBoolean());
+        }
+    }
+}