<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Evgenii Iakovenko</title>
    <description>The latest articles on DEV Community by Evgenii Iakovenko (@yakoveka).</description>
    <link>https://dev.to/yakoveka</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200028%2F958cd620-4421-441b-8d5b-476d8b222688.png</url>
      <title>DEV Community: Evgenii Iakovenko</title>
      <link>https://dev.to/yakoveka</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yakoveka"/>
    <language>en</language>
    <item>
      <title>Your Drupal node page says 403. Your RSS feed doesn't care</title>
      <dc:creator>Evgenii Iakovenko</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:47:32 +0000</pubDate>
      <link>https://dev.to/yakoveka/your-drupal-node-page-says-403-your-rss-feed-doesnt-care-4m5g</link>
      <guid>https://dev.to/yakoveka/your-drupal-node-page-says-403-your-rss-feed-doesnt-care-4m5g</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two access systems
&lt;/h2&gt;

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

&lt;p&gt;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 &lt;code&gt;NodeDatabaseHooks&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;The demo site has a private content type, &lt;code&gt;secret_note&lt;/code&gt;, and a small access hook: only the author or a user with &lt;code&gt;view any secret_note&lt;/code&gt; can view a note.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Hook('node_access')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;nodeAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;NodeInterface&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AccountInterface&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AccessResultInterface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$is_author&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view any secret_note'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AccessResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;neutral&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cachePerUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addCacheableDependency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AccessResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forbidden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Secret notes are visible to their author and staff only.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cachePerUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addCacheableDependency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice owns three notes. A small script requests a few URLs as anonymous and prints &lt;code&gt;LEAK&lt;/code&gt; when a secret title appears in the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: node grants
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;record&lt;/strong&gt; is a lock on a node. &lt;code&gt;hook_node_access_records($node)&lt;/code&gt; returns the locks, and Drupal stores them in the &lt;code&gt;node_access&lt;/code&gt; table when the node is saved.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;grant&lt;/strong&gt; is a key a user holds. &lt;code&gt;hook_node_grants($account, $operation)&lt;/code&gt; returns the keys for the current user. They are computed per request, never stored.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Core gives every user, anonymous included, the key &lt;code&gt;all / 0&lt;/code&gt;. So a lock of &lt;code&gt;all / 0&lt;/code&gt; simply means "public".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the demo, the policy is one small class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Hook('node_access_records')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;NodeInterface&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PUBLIC_BUNDLES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'all'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PRIVATE_BUNDLES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REALM_OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getOwnerId&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
      &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REALM_STAFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;#[Hook('node_grants')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;grants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AccountInterface&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$operation&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$operation&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nv"&gt;$grants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$grants&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REALM_OWNER&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view any secret_note'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$grants&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REALM_STAFF&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$grants&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here is what ends up in &lt;code&gt;node_access&lt;/code&gt; (Alice is user 2):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nid  type         realm       gid
22   secret_note  demo_owner  2
22   secret_note  demo_staff  1
26   article      all         0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the Kernel test before the hooks, and it failed first, which is the point of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'Public article'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;listedTitles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$anonymous&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'Alice note 1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Alice note 2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Public article'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;listedTitles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$alice&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'Bob note'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Public article'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;listedTitles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bob&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public articles are still in every listing, Alice sees her own notes on &lt;code&gt;/latest&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfall: a type you forgot
&lt;/h2&gt;

&lt;p&gt;Then I added a second private type, &lt;code&gt;secret_invoice&lt;/code&gt;. I remembered the access hook and forgot the grants class. The invoice page returned 403, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LEAK  /search/node?keys=quasar  (1 secret items visible)
LEAK  /taxonomy/term/1          (1 secret items visible)
LEAK  /latest                   (1 secret items visible)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The safety net is a Unit test that reads every &lt;code&gt;node.type.*.yml&lt;/code&gt; from the config sync directory and fails when a type isn't in either list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node type 'secret_invoice' is not classified as public or private in NodeGrantsHooks;
when published, core makes it public in every listing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Two more things the demo doesn't show, but you will meet them on a real site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Derived ownership.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh installs.&lt;/strong&gt; If you create sites with &lt;code&gt;site:install&lt;/code&gt;, make sure grants are rebuilt after your module is installed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Or use a contrib module
&lt;/h2&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Content Access&lt;/strong&gt; (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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions by Term&lt;/strong&gt; (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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group&lt;/strong&gt; (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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To summarize when I'd pick what:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your site in two minutes
&lt;/h2&gt;

&lt;p&gt;Open these in a private window, as anonymous:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;/rss.xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/search/node?keys=&amp;lt;a word that appears in private content&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/taxonomy/term/&amp;lt;id&amp;gt;&lt;/code&gt; for a few terms that private content uses&lt;/li&gt;
&lt;li&gt;Any listing your site has: &lt;code&gt;/node&lt;/code&gt;, views pages, "latest" blocks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then look at the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;node_access&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the only realm is &lt;code&gt;all&lt;/code&gt; and you have private content in nodes, nothing protects your listings.&lt;/p&gt;

&lt;p&gt;The demo repo has a tag for every stage, so you can see each fix as a diff: &lt;a href="https://github.com/yakoveka/drupal-access-demo" rel="noopener noreferrer"&gt;https://github.com/yakoveka/drupal-access-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

</description>
      <category>backend</category>
      <category>drupal</category>
      <category>php</category>
      <category>security</category>
    </item>
  </channel>
</rss>
