When working with Drupal entities, many developers assume that access control “just works.” This is only partially true. Drupal does provide a robust access system — but only when you interact with entities using the Entity API.
However, if you run raw SQL queries, write custom EntityQuery logic, or misconfigure Views, you can accidentally expose sensitive content to unauthorized users.
This article explains exactly why this happens, how it affects production systems, and how to fix it — with real code examples and real-world production incidents.
🔒 1. How Entity Access in Drupal Really Works
Drupal enforces entity access through:
Entity Access Control Handlers
hook_entity_access()hook_node_access()Permissions + workflows (e.g., Content Moderation, Workbench)
Field-level access checkers
These checks activate automatically only when:
✔ You load a full entity via storage
$node = \Drupal::entityTypeManager()
->getStorage('node')
->load($nid);
if ($node->access('view')) {
// Access allowed
}
✔ You render the entity as a whole
$build = \Drupal::entityTypeManager()
->getViewBuilder('node')
->view($node);
If you do anything else — Drupal assumes you know what you're doing, and access checks DO NOT run.
❗ 2. EntityQuery Does NOT Check Access (Common Developer Trap)
A typical mistake:
$query = \Drupal::entityQuery('node')
->condition('type', 'article');
$nids = $query->execute(); // Contains ALL article nodes Even unpublished or restricted nodes will appear here.
✔ Safe version
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nodes = $storage->loadMultiple($nids);
$visible_nodes = array_filter($nodes, function ($node) {
return $node->access('view');
});
❗ 3. SQL Queries Bypass ALL Security
Example:
$result = \Drupal::database()->select('node_field_data', 'n')
->fields('n', ['nid', 'title', 'status'])
->execute()
->fetchAll();
No access checks. Ever.
This query returns:
unpublished nodes,
restricted nodes (private workspaces),
content the user should never know exists.
✔ Safe alternative
Use entity storage instead of SQL.
❗ 4. Views Can Leak Content Too
Many developers believe Views is always safe.
Это не так.
Views builds SQL queries manually.
If a site admin removes access filters “to make something work,” Drupal has no way of knowing.
Example of dangerous Views setup
Access → “Unrestricted”
Display only fields (not full entities)
Disabled “Published status” filter
Custom relationship that bypasses node grants
In this case, Views may show:
titles of private content,
field values from restricted nodes,
unpublished content to anonymous users.
This caused multiple production leaks across real companies (examples ниже).
❗ 5. Rendering Fields Manually Bypasses Access
Example:
$value = $node->get('field_secret')->value;
If you didn't check $node->access('view') before this, you may reveal sensitive information.
✔ Safe version:
if ($node->access('view')) {
$value = $node->get('field_secret')->value;
}
🔥 REAL PRODUCTION CASES (from actual Drupal projects)
🚨 Case 1: Medical Portal Leaking Unpublished Data
A Drupal 9 site for a medical clinic used a custom report:
$ids = \Drupal::entityQuery('node')
->condition('type', 'patient_record')
->execute();
Doctors with limited access suddenly saw all patient records, including those assigned to other departments.
Cause: No access checks.
Fix: Added ->access('view') filtering and replaced EntityQuery with proper storage loading.
🚨 Case 2: University Portal Exposing Student Applications
A Views page listed student submissions.
An administrator disabled the access handler because “it hid some rows.”
Result:
Students could see titles of applications belonging to other users (private documents).
Cause: Views access settings disabled + displaying fields instead of nodes.
Fix:
Enabled Views access checks + switched to rendering the entire entity.
🚨 Case 3: E-commerce Store Displaying Hidden Products
A custom SQL query fetched product prices:
SELECT sku, price FROM commerce_product_field_data;
The marketing team used it in a dashboard.
Guest users who accessed the dashboard endpoint accidentally received prices for discontinued items, including confidential wholesale pricing.
Cause: Direct SQL without entity access.
✔ Best Practices to Avoid Security Failures
1. Always load entities through the Entity API
Never rely on raw SQL for content.
2. After EntityQuery — always check access
Access is not part of EntityQuery.
3. Avoid direct field rendering
Render the whole entity instead.
4. Configure Views correctly
Ensure:
Access → “Permission”
“Published” filter is enabled
Do not remove access handlers to “fix visibility issues”
5. Use view builders whenever possible
They perform automatic access checks.
🧾 Conclusion
Drupal’s access system is powerful, but only if you work within its framework.
By using:
EntityQuery without checks
SQL queries
Misconfigured Views
Direct field extraction
…you bypass access control entirely, risking severe data leaks.
The golden rule:
⭐ If you didn’t load the entity through storage, Drupal did not check access.
Follow the best practices above to keep your Drupal applications secure and predictable — especially at scale.
Avoiding Entity Access Pitfalls in Drupal: Extended Guide with Code Examples
This extended version includes deeper technical details and additional code samples demonstrating common mistakes and their secure alternatives.
1. EntityQuery Does Not Check Access
❌ Incorrect: assuming EntityQuery filters out inaccessible entities
$query = \Drupal::entityQuery('node')
->condition('type', 'article')
->condition('status', 1);
$nids = $query->execute();
// Developer mistakenly believes these nodes are all visible to the user.
foreach ($nids as $nid) {
$node = \Drupal::entityTypeManager()->getStorage('node')->load($nid);
// Rendering content without checking access.
echo $node->title->value;
}
Even if status = 1 (published), moderated content or restricted nodes will still appear.
✔ Correct: recheck access after loading entities
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nodes = $storage->loadMultiple(
\Drupal::entityQuery('node')
->condition('type', 'article')
->execute()
);
foreach ($nodes as $node) {
if ($node->access('view')) {
echo $node->label();
}
}
A standard and safe pattern.
2. Direct SQL Queries Bypass Access Control
❌ Incorrect: retrieving data from database tables directly
$results = \Drupal::database()->select('node_field_data', 'n')
->fields('n', ['nid', 'title', 'uid', 'status'])
->execute()
->fetchAll();
foreach ($results as $row) {
// User may see private, restricted, or unpublished nodes
print $row->title;
}
SQL knows nothing about Drupal permissions.
✔ Correct: use SQL only for non-sensitive operations
If you must use SQL, then load entities afterward:
$ids = \Drupal::database()->select('node_field_data', 'n')
->fields('n', ['nid'])
->execute()
->fetchCol();
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nodes = $storage->loadMultiple($ids);
foreach ($nodes as $node) {
if ($node->access('view')) {
print $node->label();
}
}
Now SQL is only used for fetching IDs; access is still enforced.
3. Access Problems in Views
Views internally uses SQL queries and can easily expose inaccessible data if configured incorrectly.
❌ Incorrect: Views displaying fields without access protection
A typical risk:
- Display type: Fields
- Access: None
- Filter: Content: Published = Yes
A user without permission to view unpublished content might still see:
- Titles
- Author names
- Custom fields
✔ Correct: enforce access checks inside Views
Recommended settings:
- Access: Permissions: View published content
- Query settings → Disable SQL rewrite: OFF
- Render: Content (Rendered entity) instead of individual fields
Additionally, a custom Views filter can enforce access:
public function query() {
$this->ensureMyTable();
$this->query->addWhereExpression(0, "1 = 1"); // placeholder
// Enforce built-in access checks.
$this->query->addTag('node_access');
}
The tag ensures Drupal rewrites the SQL query to apply node access rules.
4. Rendering Fields Without Checking Entity Access
❌ Incorrect: reading field values directly
$node = \Drupal::entityTypeManager()->getStorage('node')->load($nid);
$value = $node->get('field_secret')->value;
print $value;
If the user can load the node ID (through EntityQuery or otherwise), they might see sensitive fields.
✔ Correct: check entity access before field access
$node = \Drupal::entityTypeManager()->getStorage('node')->load($nid);
if ($node && $node->access('view')) {
echo $node->get('field_secret')->value;
}
✔ Or render with view builder (access enforced automatically)
$build = \Drupal::entityTypeManager()
->getViewBuilder('node')
->view($node, 'teaser');
return $build;
Render arrays automatically apply access checks.
5. Example: Secure Entity Loading Pattern (Reusable)
A recommended reusable helper:
/**
* Loads viewable entities of a given type based on an entity query.
*/
function load_viewable_entities($entity_type_id, array $conditions) {
$query = \Drupal::entityQuery($entity_type_id);
foreach ($conditions as $field => $value) {
$query->condition($field, $value);
}
$ids = $query->execute();
$storage = \Drupal::entityTypeManager()->getStorage($entity_type_id);
$entities = $storage->loadMultiple($ids);
return array_filter($entities, function ($entity) {
return $entity->access('view');
});
}
Usage:
$articles = load_viewable_entities('node', ['type' => 'article']);
6. Example: Protecting Custom Controllers and Routes
❌ Incorrect: returning content directly
public function listArticles() {
$nids = \Drupal::entityQuery('node')
->condition('type', 'article')
->execute();
return [
'#theme' => 'item_list',
'#items' => array_map(function ($nid) {
$node = Node::load($nid);
return $node->label();
}, $nids),
];
}
This exposes private nodes if access isn’t enforced.
✔ Correct: enforce access before rendering
public function listArticles() {
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = \Drupal::entityQuery('node')
->condition('type', 'article')
->execute();
$nodes = $storage->loadMultiple($nids);
$visible = array_filter($nodes, fn($node) => $node->access('view'));
return [
'#theme' => 'item_list',
'#items' => array_map(fn($node) => $node->label(), $visible),
];
}
Final Summary
This extended article demonstrates:
- Why EntityQuery must be combined with access checks.
- Why SQL-based fetching is dangerous.
- How Views can silently expose content.
- How to enforce secure rendering patterns.
- Best practices for controllers, render arrays, and helper functions.
Comments