DEV Community

Evgenii Iakovenko
Evgenii Iakovenko

Posted on AI-assisted

Your Drupal node page says 403. Your RSS feed doesn't care

I ran into a bug where private content leaked even though a node access hook was already in place. Opening the node directly returned 403, exactly as intended. But the same content was sitting in the RSS feed, in search results and on taxonomy term pages, available to anyone.

The hook wasn't broken. It checks permissions when someone visits the node page, and on those pages nobody was visiting the node. The content was only being listed, as if everyone had permission to see it.

Drupal has two separate access systems, and they don't talk to each other. This post shows the difference on a throwaway Drupal 11.4 site you can reproduce in a few minutes, compares the ways to fix it, and ends with a two-minute check for your own site.

Two access systems

  1. Single-entity access. When someone opens /node/123, Drupal asks hook_node_access(). This is where most of us put rules like "only the author and staff can see this".
  2. Query access. When Drupal builds a list of nodes (Views, core search, the front page feed, taxonomy term pages), it doesn't call hook_node_access() for every row. That would be far too slow. It adds a condition on the node_access table to the SQL query instead.

The main concern is the second one. If no module on your site implements node grants, core doesn't add that condition at all. The comment in NodeDatabaseHooks says it directly: if there are no node access modules, it doesn't need to alter the query. So for every listing, published means public.

The demo

The demo site has a private content type, secret_note, and a small access hook: only the author or a user with view any secret_note can view a note.

#[Hook('node_access')]
public function nodeAccess(NodeInterface $node, string $operation, AccountInterface $account): AccessResultInterface {
  // ...
  if ($is_author || $account->hasPermission('view any secret_note')) {
    return AccessResult::neutral()->cachePerUser()->addCacheableDependency($node);
  }
  return AccessResult::forbidden('Secret notes are visible to their author and staff only.')
    ->cachePerUser()
    ->addCacheableDependency($node);
}
Enter fullscreen mode Exit fullscreen mode

Alice owns three notes. A small script requests a few URLs as anonymous and prints LEAK when a secret title appears in the response:

PASS  /node/1                   (403 for anonymous)
LEAK  /rss.xml                  (2 secret titles visible)
LEAK  /search/node?keys=quasar  (3 secret titles visible)
LEAK  /taxonomy/term/1          (3 secret titles visible)
Summary: 3 leak(s), 0 fail(s)
Enter fullscreen mode Exit fullscreen mode

The node page is protected. Everything that lists nodes isn't. Nobody added an endpoint here: the feed, search and term pages all come with a standard site.

RSS shows two notes and not three because the front page feed only lists promoted nodes, and two of the notes were promoted. That's a detail of the feed, not a protection.

The fix: node grants

The fix is to tell the listing queries who may see what. That's what node grants are for, and the naming is confusing enough that it's worth translating:

  • A record is a lock on a node. hook_node_access_records($node) returns the locks, and Drupal stores them in the node_access table when the node is saved.
  • A grant is a key a user holds. hook_node_grants($account, $operation) returns the keys for the current user. They are computed per request, never stored.
  • Both are a pair of a realm (any name your module picks) and an id. A user sees a node in a listing if any of their keys fits any of its locks.
  • Core gives every user, anonymous included, the key all / 0. So a lock of all / 0 simply means "public".

In the demo, the policy is one small class:

#[Hook('node_access_records')]
public function records(NodeInterface $node): array {
  if (!$node->isPublished()) {
    return [];
  }
  if (in_array($node->bundle(), self::PUBLIC_BUNDLES, TRUE)) {
    return [self::grant('all', 0)];
  }
  if (in_array($node->bundle(), self::PRIVATE_BUNDLES, TRUE)) {
    return [
      self::grant(self::REALM_OWNER, (int) $node->getOwnerId()),
      self::grant(self::REALM_STAFF, 1),
    ];
  }
  return [];
}

#[Hook('node_grants')]
public function grants(AccountInterface $account, string $operation): array {
  if ($operation !== 'view') {
    return [];
  }
  $grants = [];
  if ($account->isAuthenticated()) {
    $grants[self::REALM_OWNER] = [(int) $account->id()];
  }
  if ($account->hasPermission('view any secret_note')) {
    $grants[self::REALM_STAFF] = [1];
  }
  return $grants;
}
Enter fullscreen mode Exit fullscreen mode

The rules mirror the access hook on purpose: author, or staff. If the two disagree, the node page and the listings will disagree again, just in a new way.

Here is what ends up in node_access (Alice is user 2):

nid  type         realm       gid
22   secret_note  demo_owner  2
22   secret_note  demo_staff  1
26   article      all         0
Enter fullscreen mode Exit fullscreen mode

I wrote the Kernel test before the hooks, and it failed first, which is the point of it:

$this->assertSame(['Public article'], $this->listedTitles($anonymous));
$this->assertSame(['Alice note 1', 'Alice note 2', 'Public article'], $this->listedTitles($alice));
$this->assertSame(['Bob note', 'Public article'], $this->listedTitles($bob));
Enter fullscreen mode Exit fullscreen mode

Existing nodes keep their old rows until grants are rebuilt, so the module ships a post update that calls \Drupal::service(NodeAccessRebuild::class)->rebuild(). On a big site, rebuild in batch mode.

With grants in place, the same check passes. I also added a typical "latest content" view at /latest, to make sure a view built later is covered too:

PASS  /node/11                  (403 for anonymous)
PASS  /rss.xml                  (200)
PASS  /search/node?keys=quasar  (200)
PASS  /taxonomy/term/1          (200)
PASS  /latest                   (200)
Summary: 0 leak(s), 0 fail(s)
Enter fullscreen mode Exit fullscreen mode

The public articles are still in every listing, Alice sees her own notes on /latest, and Bob sees only the public articles. Any view someone builds later is covered as well, because the filter lives in the query, not in the view.

The pitfall: a type you forgot

Then I added a second private type, secret_invoice. I remembered the access hook and forgot the grants class. The invoice page returned 403, and:

LEAK  /search/node?keys=quasar  (1 secret items visible)
LEAK  /taxonomy/term/1          (1 secret items visible)
LEAK  /latest                   (1 secret items visible)
Enter fullscreen mode Exit fullscreen mode

The reason is the last return [] in records(). When every module returns no locks for a published node, core doesn't leave it without any. It writes all / 0, which means public. So an unclassified type is public in every listing, even with grants in place.

The safety net is a Unit test that reads every node.type.*.yml from the config sync directory and fails when a type isn't in either list:

Node type 'secret_invoice' is not classified as public or private in NodeGrantsHooks;
when published, core makes it public in every listing.
Enter fullscreen mode Exit fullscreen mode

Classifying the type isn't enough on its own. Invoices that already exist keep their all / 0 row until grants are rebuilt, so the fix needs a second post update.

Two more things the demo doesn't show, but you will meet them on a real site:

  • Derived ownership. If a node's owner lives in another node (a linked record, a parent case), saving that other node doesn't rewrite this node's locks. You have to rewrite them yourself when the relation changes.
  • Fresh installs. If you create sites with site:install, make sure grants are rebuilt after your module is installed.

Or use a contrib module

You don't always need custom grants. I checked the three modules people usually mention (drupal.org, October 2026):

  1. Content Access (2.1.0, Drupal 9.4 to 11). Role-based access per content type, with optional per-node overrides, built on node grants. It's the same mechanism as the demo without any code. Note that it isn't covered by the security advisory policy.
  2. Permissions by Term (3.1.41, Drupal 10 and 11). Access follows taxonomy terms assigned to users or roles, also built on node grants. Feature-complete and in maintenance mode.
  3. Group (3.3.5 for Drupal 10.3 and 11; 4.0 is in alpha). A full membership model: users belong to groups with their own roles, content belongs to groups. It doesn't use node grants; it alters entity and Views queries. If you use it, test every type of listing your site has.

To summarize when I'd pick what:

  1. "Role X can see type Y" and editors manage it: Content Access.
  2. Access follows a vocabulary (department, region, client): Permissions by Term.
  3. Real memberships and organisations: Group.
  4. Ownership comes from your own domain model: custom grants, about 40 lines plus a test.

If you combine modules, keep in mind that grants add up. A user who holds any matching key sees the node, so one generous module wins over a strict one.

Check your site in two minutes

Open these in a private window, as anonymous:

  1. /rss.xml
  2. /search/node?keys=<a word that appears in private content>
  3. /taxonomy/term/<id> for a few terms that private content uses
  4. Any listing your site has: /node, views pages, "latest" blocks

Then look at the table:

SELECT realm, COUNT(*) FROM node_access GROUP BY realm;
Enter fullscreen mode Exit fullscreen mode

If the only realm is all and you have private content in nodes, nothing protects your listings.

The demo repo has a tag for every stage, so you can see each fix as a diff: https://github.com/yakoveka/drupal-access-demo

If you write an access hook, write a listing test next to it: an entity query with access checks, run as a stranger. Which listing on your site would you check first?

Top comments (0)