diff --git a/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php b/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php
index 43b16a105529896a798bc0e9aa874a6932632138..44723dd7f7f651065af603d5ad85b00f589dc5f3 100644
--- a/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php
+++ b/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php
@@ -3,15 +3,17 @@
 namespace Drupal\jsonapi\EventSubscriber;
 
 use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheableResponseInterface;
 use Drupal\jsonapi\JsonApiSpec;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Event\RequestEvent;
 use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Symfony\Component\HttpKernel\Event\ResponseEvent;
 use Symfony\Component\HttpKernel\KernelEvents;
 
 /**
- * Request subscriber that validates a JSON:API request.
+ * Subscriber that validates the query parameter names on a JSON:API request.
  *
  * @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
  *   may change at any time and could break any dependencies on it.
@@ -36,6 +38,28 @@ public function onRequest(RequestEvent $event) {
     $this->validateQueryParams($request);
   }
 
+  /**
+   * Validates JSON:API requests.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
+   *   The event to process.
+   */
+  public function onResponse(ResponseEvent $event) {
+    $request = $event->getRequest();
+    if ($request->getRequestFormat() !== 'api_json') {
+      return;
+    }
+
+    // At this point, we've already run validation on the request by checking
+    // the query arguments. This means that if the query arguments change, we
+    // may need to run this validation again. Therefore, all responses need to
+    // vary by url.query_args.
+    $response = $event->getResponse();
+    if ($response instanceof CacheableResponseInterface) {
+      $response->addCacheableDependency((new CacheableMetadata())->addCacheContexts(['url.query_args']));
+    }
+  }
+
   /**
    * Validates custom (implementation-specific) query parameter names.
    *
@@ -60,20 +84,20 @@ protected function validateQueryParams(Request $request) {
       }
     }
 
+    if (empty($invalid_query_params)) {
+      return NULL;
+    }
+
     // Drupal uses the `_format` query parameter for Content-Type negotiation.
     // Using it violates the JSON:API spec. Nudge people nicely in the correct
     // direction. (This is special cased because using it is pretty common.)
     if (in_array('_format', $invalid_query_params, TRUE)) {
       $uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
-      $exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args:_format']), 'JSON:API does not need that ugly \'_format\' query string! 🤘 Use the URL provided in \'links\' 🙏');
+      $exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args']), 'JSON:API does not need that ugly \'_format\' query string! 🤘 Use the URL provided in \'links\' 🙏');
       $exception->setHeaders(['Link' => $uri_without_query_string]);
       throw $exception;
     }
 
-    if (empty($invalid_query_params)) {
-      return NULL;
-    }
-
     $message = sprintf('The following query parameters violate the JSON:API spec: \'%s\'.', implode("', '", $invalid_query_params));
     $exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args']), $message);
     $exception->setHeaders(['Link' => 'http://jsonapi.org/format/#query-parameters']);
@@ -85,6 +109,10 @@ protected function validateQueryParams(Request $request) {
    */
   public static function getSubscribedEvents(): array {
     $events[KernelEvents::REQUEST][] = ['onRequest'];
+
+    // Run before the resource response subscriber (priority 128), so that said
+    // subscriber gets the cacheable metadata from this one.
+    $events[KernelEvents::RESPONSE][] = ['onResponse', 129];
     return $events;
   }
 
diff --git a/core/modules/jsonapi/tests/src/Functional/BlockTest.php b/core/modules/jsonapi/tests/src/Functional/BlockTest.php
index 0adc2dadcdbc09f9f485143c2a365c371db601dd..5efb77ef42e61f70d11e55697f00da8cca94b8b4 100644
--- a/core/modules/jsonapi/tests/src/Functional/BlockTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/BlockTest.php
@@ -184,7 +184,7 @@ protected function getExpectedUnauthorizedAccessCacheability() {
         'http_response',
         'user:2',
       ])
-      ->setCacheContexts(['url.site', 'user.roles']);
+      ->setCacheContexts(['url.query_args', 'url.site', 'user.roles']);
   }
 
   /**
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php b/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php
index 534eb3b83efefe8140e3fdee1fda54655aa9391b..23e6de9451687106958d57cf96c8c693a2ccfab8 100644
--- a/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php
@@ -173,7 +173,7 @@ protected function getSparseFieldSets() {
   protected function getExpectedCacheContexts(?array $sparse_fieldset = NULL) {
     $cache_contexts = parent::getExpectedCacheContexts($sparse_fieldset);
     if ($sparse_fieldset === NULL || in_array('computed_test_cacheable_string_field', $sparse_fieldset)) {
-      $cache_contexts = Cache::mergeContexts($cache_contexts, ['url.query_args:computed_test_cacheable_string_field']);
+      $cache_contexts = Cache::mergeContexts($cache_contexts, ['url.query_args']);
     }
 
     return $cache_contexts;
diff --git a/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php b/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php
index d6fc9884175af3941b97fdebf1a274babaad832a..8e6aa6d09b2ff9a650580cf382a9908ee3e25473 100644
--- a/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php
@@ -46,6 +46,7 @@ public function testEntryPoint(): void {
     $response = $this->request('GET', Url::fromUri('base://jsonapi'), $request_options);
     $document = $this->getDocumentFromResponse($response);
     $expected_cache_contexts = [
+      'url.query_args',
       'url.site',
       'user.roles:authenticated',
     ];
diff --git a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
index 354904df3898a8c50370e33304592d3c9ee798b6..16e2de4a2fa402139b3c404d3c5a29e7bbae9bcc 100644
--- a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
@@ -319,7 +319,7 @@ public function testPostFileUploadAndUseInSingleRequest(): void {
     // This request fails despite the upload succeeding, because we're not
     // allowed to view the entity we're uploading to.
     $response = $this->fileRequest($uri, $this->testFileData);
-    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.site', 'user.permissions']);
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site', 'user.permissions']);
 
     $this->setUpAuthorization('GET');
 
diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
index 710fa6577f44a0e5f2046233be5f986c54699866..fa1d5828cddc300d416fcf20310dbc705816e3f3 100644
--- a/core/modules/jsonapi/tests/src/Functional/NodeTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
@@ -345,7 +345,7 @@ public function testGetIndividual(): void {
       $response,
       '/data',
       ['4xx-response', 'http_response', 'node:1'],
-      ['url.query_args:resourceVersion', 'url.site', 'user.permissions'],
+      ['url.query_args', 'url.site', 'user.permissions'],
       FALSE,
       'MISS'
     );
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
index d5d8774e6f4423b91d43fb8ccbdb19f972b5ab60..2f73786a5e7b6cf3184806fb160e87f243dfd93b 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
@@ -525,7 +525,7 @@ protected static function getAccessDeniedResponse(EntityInterface $entity, Acces
       'jsonapi' => static::$jsonApiMember,
       'errors' => [$error],
     ], 403))
-      ->addCacheableDependency((new CacheableMetadata())->addCacheTags(['4xx-response', 'http_response'])->addCacheContexts(['url.site']))
+      ->addCacheableDependency((new CacheableMetadata())->addCacheTags(['4xx-response', 'http_response'])->addCacheContexts(['url.query_args', 'url.site']))
       ->addCacheableDependency($access);
   }
 
@@ -545,8 +545,7 @@ protected function getEmptyCollectionResponse($cardinality, $self_link) {
     // If the entity type is revisionable, add a resource version cache context.
     $cache_contexts = Cache::mergeContexts([
       // Cache contexts for JSON:API URL query parameters.
-      'url.query_args:fields',
-      'url.query_args:include',
+      'url.query_args',
       // Drupal defaults.
       'url.site',
     ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
index f95f4a4c1c399a6d2aef9b856c4633d940d33498..5960ca8a895450728daa629f5ad4bb021f15ccdb 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -495,7 +495,7 @@ protected function getPatchDocument() {
   protected function getExpectedUnauthorizedAccessCacheability() {
     return (new CacheableMetadata())
       ->setCacheTags(['4xx-response', 'http_response'])
-      ->setCacheContexts(['url.site', 'user.permissions'])
+      ->setCacheContexts(['url.query_args', 'url.site', 'user.permissions'])
       ->addCacheContexts($this->entity->getEntityType()->isRevisionable()
         ? ['url.query_args:resourceVersion']
         : []
@@ -546,8 +546,7 @@ protected function getExtraRevisionCacheTags() {
   protected function getExpectedCacheContexts(?array $sparse_fieldset = NULL) {
     $cache_contexts = [
       // Cache contexts for JSON:API URL query parameters.
-      'url.query_args:fields',
-      'url.query_args:include',
+      'url.query_args',
       // Drupal defaults.
       'url.site',
       'user.permissions',
@@ -608,11 +607,7 @@ protected static function getExpectedCollectionCacheability(AccountInterface $ac
     $cacheability->addCacheTags($entity_type->getListCacheTags());
     $cache_contexts = [
       // Cache contexts for JSON:API URL query parameters.
-      'url.query_args:fields',
-      'url.query_args:filter',
-      'url.query_args:include',
-      'url.query_args:page',
-      'url.query_args:sort',
+      'url.query_args',
       // Drupal defaults.
       'url.site',
     ];
@@ -1077,12 +1072,12 @@ public function testGetIndividual(): void {
     $message_url = clone $url;
     $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
     $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
-    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE');
+    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'UNCACHEABLE');
 
     // DX: when Accept request header is missing, still 404, same response.
     unset($request_options[RequestOptions::HEADERS]['Accept']);
     $response = $this->request('GET', $url, $request_options);
-    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE');
+    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'UNCACHEABLE');
   }
 
   /**
@@ -1157,8 +1152,7 @@ public function testCollection(): void {
       $expected_error_message = "The current user is not authorized to filter by the `field_jsonapi_test_entity_ref` field, given in the path `field_jsonapi_test_entity_ref`. The 'field_jsonapi_test_entity_ref view access' permission is required.";
       $expected_cache_tags = ['4xx-response', 'http_response'];
       $expected_cache_contexts = [
-        'url.query_args:filter',
-        'url.query_args:sort',
+        'url.query_args',
         'url.site',
         'user.permissions',
       ];
@@ -1710,7 +1704,7 @@ protected function doTestRelationshipMutation(array $request_options) {
    */
   protected function getExpectedGetRelationshipResponse($relationship_field_name, ?EntityInterface $entity = NULL) {
     $entity = $entity ?: $this->entity;
-    $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+    $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args'] : []);
     $access = $access->orIf(static::entityFieldAccess($entity, $this->resourceType->getInternalName($relationship_field_name), 'view', $this->account));
     if (!$access->isAllowed()) {
       $via_link = Url::fromRoute(
@@ -1724,8 +1718,7 @@ protected function getExpectedGetRelationshipResponse($relationship_field_name,
       ->addCacheTags(['http_response'])
       ->addCacheContexts([
         'url.site',
-        'url.query_args:include',
-        'url.query_args:fields',
+        'url.query_args',
       ])
       ->addCacheableDependency($entity)
       ->addCacheableDependency($access);
@@ -1922,7 +1915,7 @@ protected function getExpectedRelatedResponse($relationship_field_name, array $r
     // every related resource.
     $base_resource_identifier = static::toResourceIdentifier($entity);
     $internal_name = $this->resourceType->getInternalName($relationship_field_name);
-    $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+    $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args'] : []);
     $access = $access->orIf(static::entityFieldAccess($entity, $internal_name, 'view', $this->account));
     if (!$access->isAllowed()) {
       $detail = 'The current user is not allowed to view this relationship.';
@@ -1944,8 +1937,7 @@ protected function getExpectedRelatedResponse($relationship_field_name, array $r
       if (empty($relationship_document['data'])) {
         $cache_contexts = Cache::mergeContexts([
           // Cache contexts for JSON:API URL query parameters.
-          'url.query_args:fields',
-          'url.query_args:include',
+          'url.query_args',
           // Drupal defaults.
           'url.site',
         ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
@@ -3041,8 +3033,7 @@ public function testRevisions(): void {
     $actual_response = $this->request('GET', $rel_working_copy_collection_url_filtered, $request_options);
     $filtered_collection_expected_cache_contexts = [
       'url.path',
-      'url.query_args:filter',
-      'url.query_args:resourceVersion',
+      'url.query_args',
       'url.site',
     ];
     $this->assertResourceErrorResponse(501, 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.', $rel_working_copy_collection_url_filtered, $actual_response, FALSE, ['http_response'], $filtered_collection_expected_cache_contexts);
@@ -3050,7 +3041,7 @@ public function testRevisions(): void {
     $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options);
     $invalid_version_expected_cache_contexts = [
       'url.path',
-      'url.query_args:resourceVersion',
+      'url.query_args',
       'url.site',
     ];
     $this->assertResourceErrorResponse(400, 'Collection resources only support the following resource version identifiers: rel:latest-version, rel:working-copy', $rel_invalid_collection_url, $actual_response, FALSE, ['4xx-response', 'http_response'], $invalid_version_expected_cache_contexts);
diff --git a/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php b/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
index ade4cf3b6903f156b84c1630735ec0a94b43408a..c07992ca8013a856810f9d2f4e8e968fe73a4784 100644
--- a/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
+++ b/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
@@ -104,7 +104,7 @@ public function testApiJsonNotSupportedInRest(): void {
       FALSE,
       $response,
       ['4xx-response', 'config:system.logging', 'config:user.role.anonymous', 'http_response', 'node:1'],
-      ['url.query_args:_format', 'url.site', 'user.permissions'],
+      ['url.query_args', 'url.site', 'user.permissions'],
       'MISS',
       'MISS'
     );
diff --git a/core/modules/jsonapi/tests/src/Functional/UserTest.php b/core/modules/jsonapi/tests/src/Functional/UserTest.php
index a7db8bd9aa5ff97401aaf8e619c495fa8a0d8d75..7231798f6f98d7906096b4cc7c3b51da30a830ca 100644
--- a/core/modules/jsonapi/tests/src/Functional/UserTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php
@@ -458,7 +458,7 @@ public function testQueryInvolvingRoles(): void {
     $this->grantPermissionsToTestedRole(['administer users']);
 
     $response = $this->request('GET', $collection_url, $request_options);
-    $expected_cache_contexts = ['url.path', 'url.query_args:filter', 'url.site'];
+    $expected_cache_contexts = ['url.path', 'url.query_args', 'url.site'];
     $this->assertResourceErrorResponse(400, "Filtering on config entities is not supported by Drupal's entity API. You tried to filter on a Role config entity.", $collection_url, $response, FALSE, ['4xx-response', 'http_response'], $expected_cache_contexts, FALSE, 'MISS');
   }
 
diff --git a/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php b/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php
index 35f84c346cd7272854d4e75548473d5b169c04d9..a5a919d1ef0352f196c0cef3c9d0b70d0218c8a0 100644
--- a/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php
+++ b/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php
@@ -105,8 +105,7 @@ public function doTestCollectionFilterAccessBasedOnPermissions($label_field_name
     $message = "The current user is not authorized to filter by the `spotlight` field, given in the path `spotlight`.";
     $expected_cache_tags = ['4xx-response', 'http_response'];
     $expected_cache_contexts = [
-      'url.query_args:filter',
-      'url.query_args:sort',
+      'url.query_args',
       'url.site',
       'user.permissions',
     ];