diff --git a/core/core.services.yml b/core/core.services.yml
index bb73e29d505bc93dec5e23853174d9127cdd04e0..84abfc516c4b0792ecfef0c5afcbab4620e0f176 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -152,6 +152,12 @@ services:
     tags:
       - { name: cache.context }
 
+  # Pseudo cache context, not intended for direct use.
+  cache_context.pseudo:
+    class: Drupal\Core\Cache\Context\PseudoCacheContext
+    tags:
+      - { name: cache.context }
+
   # Complex cache contexts, that depend on the routing system.
   cache_context.route:
     class: Drupal\Core\Cache\Context\RouteCacheContext
diff --git a/core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php b/core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php
new file mode 100644
index 0000000000000000000000000000000000000000..7e1300bfa15883ada2378416d4fa2c1ac947c8ba
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\Core\Cache\Context;
+
+use Drupal\Core\Cache\CacheableMetadata;
+
+/**
+ * Defines the PseudoCacheContext service.
+ *
+ * This cache context will always return the value of '1', but is not intended
+ * to be added manually. Instead, users are expected to use the associated
+ * helper class \Drupal\Core\Cache\DependencyVariation instead.
+ *
+ * Cache context ID: 'pseudo'.
+ *
+ * @see \Drupal\Core\Cache\DependencyVariation
+ */
+class PseudoCacheContext implements CalculatedCacheContextInterface {
+
+  const ID = 'pseudo';
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return t('Pseudo');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext($parameter = NULL) {
+    return 1;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheableMetadata($parameter = NULL) {
+    return new CacheableMetadata();
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Cache/DependencyVariation.php b/core/lib/Drupal/Core/Cache/DependencyVariation.php
new file mode 100644
index 0000000000000000000000000000000000000000..207df45fbd5a1f14ae9879365a87031d40edf6c6
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/DependencyVariation.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+use Drupal\Core\Cache\Context\PseudoCacheContext;
+
+/**
+ * Helper class to add a pseudo cache context as a dependency.
+ *
+ * This is intended to be used when you have a code flow that varies based on a
+ * (property of a) dependency, but you have no way to represent said variation
+ * with a regular cache context. An example would be the passed in entity to an
+ * access control handler: You cannot know where this entity came from, so how
+ * would you know what cache context to use?
+ *
+ * You are supposed to use this helper class to wrap the original dependency and
+ * then pass in this class as a cacheable dependency itself. It will then
+ * compile the cache context identifier for you and add it to the cacheable
+ * metadata this helper class was added to.
+ *
+ * @ingroup cache
+ */
+class DependencyVariation implements CacheableDependencyInterface {
+
+  use CacheableDependencyTrait;
+
+  public function __construct(CacheableDependencyInterface $dependency) {
+    $this->cacheTags = $dependency->getCacheTags();
+    $this->cacheMaxAge = $dependency->getCacheMaxAge();
+    assert(empty($dependency->getCacheContexts()), 'A dependency passed into DependencyVariation should not contain any cache contexts.');
+    sort($this->cacheTags);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    $identifier = implode(',', $this->cacheTags) . '|' . $this->cacheMaxAge;
+    return PseudoCacheContext::ID . ':' . $identifier;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    return Cache::PERMANENT;
+  }
+
+  /**
+   * Checks if a cache context identifier represents a pseudo cache context.
+   *
+   * @param string $cache_context
+   *   The cache context identifier.
+   *
+   * @return bool
+   *   Whether the cache context identifier is for a pseudo cache context.
+   */
+  public static function isPseudoCacheContext(string $cache_context): bool {
+    return str_starts_with($cache_context, PseudoCacheContext::ID . ':');
+  }
+
+  /**
+   * Parses a pseudo cache context into a cacheable dependency.
+   *
+   * @param string $cache_context
+   *   The cache context identifier.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The cacheable metadata that was embedded in the identifier. Note that
+   *   this will only ever contain cache tags and max-age.
+   */
+  public static function parsePseudoCacheContext(string $cache_context): CacheableMetadata {
+    [, $parameter] = explode(':', $cache_context, 2);
+    [$cache_tag_string, $max_age] = explode('|', $parameter);
+    return (new CacheableMetadata())
+      ->addCacheTags(explode(',', $cache_tag_string))
+      ->setCacheMaxAge($max_age);
+  }
+
+}
diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php
index 963ab53ded4129547c9480f9d29937de2ff7b21a..743f614e61c8c1932d5fe2d676e8e167e440f1de 100644
--- a/core/modules/node/src/NodeAccessControlHandler.php
+++ b/core/modules/node/src/NodeAccessControlHandler.php
@@ -216,14 +216,31 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun
       return NULL;
     }
 
+    // Due to the check below, it is not possible to rely only on account
+    // permissions to determine whether the 'view own unpublished content'
+    // permission can be checked, instead we also need to check if the user has
+    // the authenticated role. Just in case anonymous and authenticated users
+    // are both granted the 'view own unpublished content' permission and also
+    // have otherwise identical permissions.
     $cacheability->addCacheContexts(['user.roles:authenticated']);
+
     // The "view own unpublished content" permission must not be granted
     // to anonymous users for security reasons.
     if (!$account->isAuthenticated()) {
       return NULL;
     }
 
+    // When access is granted due to the 'view own unpublished content'
+    // permission and for no other reason, node grants are bypassed. However,
+    // to ensure the full set of cacheable metadata is available to variation
+    // cache, additionally add the node_grants cache context so that if the
+    // status or the owner of the node changes, cache redirects will continue to
+    // reflect the latest state without needing to be invalidated.
     $cacheability->addCacheContexts(['user']);
+    if ($this->moduleHandler->hasImplementations('node_grants')) {
+      $cacheability->addCacheContexts(['user.node_grants:view']);
+    }
+
     if ($account->id() != $node->getOwnerId()) {
       return NULL;
     }
diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8946ca75fefeee4734ce41eafc516ac136b8a5d7
--- /dev/null
+++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\node\Functional;
+
+/**
+ * Tests the node access grants cache context service.
+ *
+ * @group node
+ * @group Cache
+ */
+class NodeAccessCacheRedirectWarningTest extends NodeTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['block', 'node_access_test_empty'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    node_access_rebuild();
+  }
+
+  /**
+   * Quick demonstration of the differences in cache contexts.
+   *
+   * The intent here was to visit the nodes to view the error but for whatever
+   * reason I can't seem to trigger the redirect warning this way. Needs work.
+   */
+  public function testNodeAccessCacheRedirectWarning(): void {
+    $this->drupalPlaceBlock('local_tasks_block');
+
+    $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('node_grants'));
+
+    $author = $this->drupalCreateUser([
+      'create page content',
+      'edit any page content',
+      'view own unpublished content',
+    ]);
+    $this->drupalLogin($author);
+
+    $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]);
+
+    $this->drupalGet($node->toUrl());
+
+    $node->setPublished();
+    $node->save();
+
+    $this->drupalGet($node->toUrl());
+
+    $node->setUnpublished();
+    $node->save();
+
+    $this->drupalGet($node->toUrl());
+
+    $node->setPublished();
+    $node->save();
+
+    $this->drupalGet($node->toUrl());
+  }
+
+}