Access Control and Permissions in Code

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Every decision about "who may do what" in Drupal is eventually a question asked in code: may this account reach this route, see this entity, edit this field, or hold this permission at all? This page is for module developers. It shows how to declare permissions, how to enforce them at each layer (routes, entities, fields, listings, node grants) and how the Access Policy API, introduced in Drupal 10.3, changes where permissions come from. All the examples belong to the meridian_collection module of the fictitious Meridian Museum site, where curators manage the museum’s collection, partner museums see only the exhibitions shared with them, and a few extra rights depend on the season.

It was checked on 2026-10-30 against Drupal 11.4.x, PHP 8.3+ and Drush 13/14. Drupal 10 reaches end of life on 2026-12-09, so new code targets Drupal 11. Drupal 12.0.0 is scheduled for the week of 2026-12-07; the APIs on this page are part of the supported 11.x API and the code uses no deprecated call. The site-builder view of roles, the permission grid, user 1 and the Access Policy API is in Users, Roles and Permissions. Routes and the full list of core requirement keys are in Routing and Controllers, the entity type that most examples protect is built in Custom Entity Types, and the hook classes used below are explained in Hooks and OOP Hooks.

The layers of access control

Drupal has no single access check. A request passes through several independent layers, each answering a narrower question, and a feature is only as secure as the weakest layer that protects it.

flowchart TD P["Permissions: names declared in MODULE.permissions.yml, granted to roles"] --> CP["Calculated permissions: access policies build the set for an account"] CP --> HP["hasPermission: the permission_checker service"] HP --> RT["Route access: _permission, _custom_access, _entity_access, access_check services"] HP --> EN["Entity access: access handler plus hook_entity_access and hook_ENTITY_TYPE_access"] EN --> FL["Field access: checkFieldAccess and hook_entity_field_access"] EN --> ND["Node access: node grants, bypass node access, node_access query tag"] RT --> CT["Controller or form runs"] EN --> CT FL --> CT ND --> QR["Listings: queries with accessCheck TRUE and Views"]

Four ideas organise the rest of the page:

  • Permissions are the vocabulary. A permission is a string such as edit collection objects. Site builders grant it to roles in the UI, so code that checks a permission, rather than a role id, keeps working when a site restructures its roles.

  • Access results are the grammar. Every check returns an AccessResult object (allowed, forbidden or neutral) carrying cacheability metadata. Booleans are only a convenience for the final yes or no.

  • Handlers and hooks are the sentences. Routes, entities and fields ask handlers for results; other modules can contribute opinions through hooks.

  • Access policies decide what a permission means for an account. Since 10.3 the set of permissions an account holds is computed by tagged services, with role permissions only one of the sources.

Declaring permissions

A module declares permissions in MODULE.permissions.yml at the module root. Core discovers the file with a YAML discovery, so no registration is needed beyond clearing the cache. Each top-level key is the permission’s machine name, and the value is a map.

Key Meaning

title

Required. The name shown on the People > Permissions screen. It is translatable, so write it in English.

description

Optional extra text shown under the title. Use it to say what the permission exposes.

restrict access

Optional boolean, false by default. When true, the permission screen shows a warning that it should be given to trusted roles only. Set it on every permission that allows privilege escalation, configuration changes, code or markup input, or access to other people’s data.

dependencies

Optional. The configuration or modules the permission depends on (for example config: [node.type.exhibition]), so that removing the dependency also removes the permission from roles. It is mostly used by generated permissions, see below.

provider

Optional. The module that owns the permission. It defaults to the module whose file declares it, so it is normally omitted.

The permissions of the collection entity (view, edit, delete, revisions) were declared in Custom Entity Types. The module also needs permissions that are not tied to one entity operation: one for the curator-only routes, one for the seasonal loan screens, one for a sensitive field and one for objects under embargo.

# meridian_collection.permissions.yml (additions to the entity permissions)
curate meridian collection:
  title: 'Curate the Meridian collection'
  description: 'Mark objects as on display, reorder galleries and start synchronisation runs.'

manage object loans:
  title: 'Manage object loans'
  description: 'Create and edit loan agreements. Normally granted to curators only during the loan season.'
  restrict access: true

view object valuation:
  title: 'View the insurance valuation of collection objects'
  description: 'Shows the valuation field, which is commercially sensitive.'
  restrict access: true

view embargoed collection objects:
  title: 'View embargoed collection objects'
  description: 'Objects acquired under an embargo are hidden from visitors until the embargo date.'

administer meridian collection settings:
  title: 'Administer Meridian collection settings'
  restrict access: true

permission_callbacks:
  - Drupal\meridian_collection\FeaturedPermissions::permissions

Conventions that keep a permission list maintainable:

  • Name permissions after the ability, not the screen: view object valuation, not access valuation tab. Use the imperative, lower case and the plural noun style core uses (edit own exhibition content).

  • One permission, one ability. A permission that opens two unrelated things forces site builders to over-grant.

  • Never check for a role. A site can rename curator to conservator; it cannot rename a permission without breaking the module. Keep _role and in_array('curator', $account->getRoles()) out of module code.

  • Permissions are not configuration, but the roles holding them are. The grant (role to permission) is exported with the role; the declaration lives in code. Renaming or removing a permission therefore needs an update function that cleans the roles, see Update API and Post-Updates.

  • A permission nobody checks is a trap. Declare only what the code enforces, and cover it with a test.

Dynamic permissions

When the set of permissions depends on site data, such as one permission per content type, list a callback under the reserved key permission_callbacks. Core calls each callable when it builds the permission list and merges the arrays they return with the static ones. Static YAML entries win on a name clash.

The museum’s kiosk shows "featured" content. Which content types editors may feature should be configurable per type, so the module generates one permission for each content type (exhibition, event, news and any type added later). Content types are configuration entities, which makes them the right dependency: a permission generated for a deleted type must disappear with it. The BundlePermissionHandlerTrait (available since Drupal 9.3) adds exactly that dependency to every permission it builds.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\BundlePermissionHandlerTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\node\NodeTypeInterface;

/**
 * Generates one "feature on the kiosk" permission per content type.
 */
final class FeaturedPermissions implements ContainerInjectionInterface {

  use AutowireTrait;
  use BundlePermissionHandlerTrait;
  use StringTranslationTrait;

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
   * Returns the permissions, keyed by machine name.
   *
   * @return array<string, array<string, mixed>>
   */
  public function permissions(): array {
    $types = $this->entityTypeManager->getStorage('node_type')->loadMultiple();
    return $this->generatePermissions($types, $this->buildPermissions(...));
  }

  /**
   * Builds the permissions of one content type.
   */
  protected function buildPermissions(NodeTypeInterface $type): array {
    $id = $type->id();
    $args = ['%type' => $type->label()];
    return [
      "feature $id content on kiosk" => [
        'title' => $this->t('%type: Feature content on the gallery kiosk', $args),
        'description' => $this->t('Lets editors pin %type content to the kiosk home screen.', $args),
      ],
    ];
  }

}

The points to take from the example:

  • The callback is resolved through the class resolver, so a class that implements ContainerInjectionInterface gets its services injected. AutowireTrait provides the create() method for it. Do not call \Drupal::entityTypeManager().

  • Every permission produced by generatePermissions() carries dependencies: config: [node.type.<id>]. Without a dependency, a role keeps a grant to a permission whose type was deleted, and drush config:import of an environment that lacks the type fails validation.

  • The titles use placeholders and $this->t(), never concatenation.

  • Dynamic permissions are collected when the permission list is built and cached, so a callback that reads data other than configuration entities needs a cache rebuild (or its own invalidation) before a change shows up on the permission page.

  • Do not generate permissions from content entities such as taxonomy terms. A permission per term would change whenever an editor adds a term, is not exported with configuration, and cannot declare a stable dependency. Use a configuration entity or a settings value, or model the rule as an access policy or an entity access check.

Entities with bundles can also get their permissions from the entity type definition. Setting permission_granularity to bundle (the default is entity_type) together with the permission_provider handler makes core generate the standard view, create, update and delete permissions for every bundle, which is how node permissions such as edit any exhibition content exist. Hand-written permissions remain the right choice for a simple type.

Checking permissions in code

The primitive operation is asking whether an account holds a permission.

// Anywhere you have an account object.
if ($account->hasPermission('view object valuation')) {
  // ...
}

Behind that call, the account delegates to the permission_checker service, which asks the access policy processor for the account’s calculated permissions and tests the permission against them. Three rules apply:

  • Use the account you are given. A controller method receives or injects the current user (AccountProxyInterface), a hook receives the $account being checked, and a service that must work for another user receives an AccountInterface. Checking the current user while deciding about a different one is the classic escalation bug. Static calls such as \Drupal::currentUser() belong only in procedural code and tests; in classes inject AccountProxyInterface through the constructor.

  • A boolean loses cacheability. hasPermission() is correct for a decision made during a single request, such as hiding a button in a form builder, but a block, controller or access callback whose output is cached must also declare that the output varies by permissions. Prefer AccessResult::allowedIfHasPermission(), which adds the cache context for you.

  • Permissions are not roles. Administrator roles (is_admin) and user 1 pass every hasPermission() check unless the site disabled super-user access with the security.enable_super_user container parameter (10.3). Never write hasPermission() into a security decision and then forget to test the administrator path.

In render arrays the standard way to gate a single element is #access, which accepts a boolean or an AccessResult object; the renderer merges the result’s cacheability into the parent. In a form builder:

$form['valuation'] = [
  '#type' => 'number',
  '#title' => $this->t('Insurance valuation'),
  // An AccessResult keeps the cache metadata; a plain boolean does not.
  '#access' => AccessResult::allowedIfHasPermission($this->currentUser, 'view object valuation'),
];

#access only hides output. It never replaces the server-side check of the submitted value: a form submit handler or route that changes the valuation must itself verify the permission, because a crafted request can post a field that was never rendered. Field access (below) is the layer that enforces this for entity fields.

Route access

Routes declare who may reach them in the requirements section of MODULE.routing.yml; the full table of core keys is in Routing and Controllers. This page adds the points that matter for correctness.

# meridian_collection.routing.yml
meridian_collection.curator_dashboard:
  path: '/admin/content/collection/curate'
  defaults:
    _controller: 'Drupal\meridian_collection\Controller\CuratorController::dashboard'
    _title: 'Curate the collection'
  requirements:
    _permission: 'curate meridian collection'

meridian_collection.loans:
  path: '/admin/content/collection/loans'
  defaults:
    _controller: 'Drupal\meridian_collection\Controller\LoanController::list'
    _title: 'Object loans'
  requirements:
    # A comma means AND, a plus means OR.
    _permission: 'manage object loans, curate meridian collection'

meridian_collection.object_embargoed:
  path: '/collection/{collection_object}/embargo'
  defaults:
    _controller: 'Drupal\meridian_collection\Controller\CollectionController::embargo'
    _title: 'Embargo notice'
  requirements:
    _entity_access: 'collection_object.view'
    _meridian_embargo: 'TRUE'
  options:
    parameters:
      collection_object:
        type: entity:collection_object

How the checks combine and fail:

  • Default deny. A route with no requirement that any checker recognises is denied. A public route needs _access: 'TRUE' (a string, not a YAML boolean) or _permission: 'access content'.

  • All requirements must pass. With the default _access_mode: ALL, meridian_collection.object_embargoed needs both _entity_access and _meridian_embargo to allow. Setting _access_mode: ANY under options requires only one. Inside one _permission string, the comma and plus operators choose AND or OR for that requirement.

  • 403 versus 404. Upcasting runs first, so a missing object is a 404 for every visitor, including those who could not have seen it. If the existence of an object is itself sensitive, make the entity access handler deny view and accept the 403, or return a 404 deliberately from a controller.

  • State-changing routes need CSRF protection. A route that changes data from a link (toggling "on display", for instance) adds _csrf_token: 'TRUE', and links to it are generated with Url::fromRoute(), which attaches the token. A GET route with side effects and without it can be triggered from any web page the curator visits.

  • Menu links, tabs and Url::access() use the same checks. That is the benefit of the declarative form; an AccessDeniedHttpException thrown inside the controller body is invisible to them.

A custom access checker

A requirement key that does not exist yet is added with a service tagged access_check. The applies_to attribute names the key, and the class implements the marker interface AccessInterface and exposes an access() method whose arguments are resolved by type and name (Route, RouteMatchInterface, AccountInterface, the request and the upcast parameters).

The museum embargoes some objects until a date. Visitors may not see an embargoed object, staff with the permission may, and the answer changes by itself at midnight on the embargo date. The result therefore needs a cache max-age, which is the part developers most often forget.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Access;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Access\AccessInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;

/**
 * Hides embargoed collection objects from accounts without the permission.
 */
final class EmbargoAccessCheck implements AccessInterface {

  public function __construct(private readonly TimeInterface $time) {}

  /**
   * Checks access for the _meridian_embargo requirement.
   */
  public function access(AccountInterface $account, CollectionObjectInterface $collection_object): AccessResultInterface {
    $until = $collection_object->getEmbargoUntil();
    $now = $this->time->getRequestTime();

    if ($until === NULL || $until <= $now) {
      // No embargo, or it has expired: no opinion about the object itself.
      $result = AccessResult::allowed();
    }
    else {
      $result = AccessResult::allowedIfHasPermission($account, 'view embargoed collection objects');
      // The answer flips when the embargo ends, even if nothing is saved.
      $result->setCacheMaxAge($until - $now);
    }

    // Saving the object (a new embargo date) invalidates the cached answer.
    return $result->addCacheableDependency($collection_object);
  }

}
# meridian_collection.services.yml
services:
  meridian_collection.access.embargo:
    class: Drupal\meridian_collection\Access\EmbargoAccessCheck
    autowire: true
    tags:
      - { name: access_check, applies_to: _meridian_embargo }

The example assumes the collection_object entity gained an embargo_until timestamp base field and a matching getEmbargoUntil() method on its interface; adding a base field to an existing entity type is an update function, see Update API and Post-Updates. Details that matter:

  • An access checker is a service, instantiated once. It must not keep per-request state in properties.

  • A checker returns an AccessResultInterface object (allowed, neutral or forbidden), never a plain boolean, so that its cacheability metadata is kept.

  • Use AccessResult::forbidden() only when the answer is final. If another checker may still grant access, return neutral (AccessResult::allowedIf(...) returns neutral when its condition is false).

  • The upcast parameter keeps its name from the route ($collection_object), not the class name.

Checking access outside a request

Code that must know whether a route is reachable, such as a block that lists links, uses the access manager or the Url object instead of duplicating the rule:

$url = Url::fromRoute('meridian_collection.curator_dashboard');
// Returns a boolean by default; pass TRUE as the second argument for the AccessResult.
$result = $url->access($this->currentUser, TRUE);
$build['link'] = Link::fromTextAndUrl($this->t('Curate'), $url)->toRenderable();
$build['link']['#access'] = $result;

Source: API: Url

Using the access result as #access carries its cache metadata into the block; a plain boolean would let the first visitor’s decision leak into the cached render of the next.

AccessResult and cacheability

All access APIs return an object implementing AccessResultInterface. It has three states, and the distinction between the last two is the most important thing to understand about Drupal access control.

State Meaning

Allowed

This checker positively grants access.

Neutral

This checker has no opinion. It neither grants nor denies; other checkers decide. If every checker is neutral the final answer is "no access".

Forbidden

This checker denies access and no other checker can override it.

Results are created with static factories and combined with orIf() and andIf():

Factory or method Behaviour

AccessResult::allowed(), forbidden($reason), neutral($reason)

Create a result of that state. The optional reason is for developers, not translated, and available through AccessResultReasonInterface::getReason().

allowedIf($condition)

Allowed when the condition is true, otherwise neutral.

forbiddenIf($condition)

Forbidden when the condition is true, otherwise neutral.

allowedIfHasPermission($account, $permission) and allowedIfHasPermissions($account, $permissions, $conjunction)

Allowed when the account has the permission (or the list, combined with AND or OR), otherwise neutral. They add the user.permissions cache context.

$a->orIf($b)

Forbidden if either is forbidden; otherwise allowed if either is allowed; otherwise neutral. Used to add grants.

$a->andIf($b)

Forbidden if either is forbidden; allowed only if both are allowed; otherwise neutral. Used to require several conditions.

isAllowed(), isForbidden(), isNeutral()

Test the state. Only isAllowed() means "go ahead".

The common mistake is to use forbiddenIf(!$condition) where allowedIf($condition) was meant, or the reverse. A rule "curators may edit unless the object is archived" is allowedIfHasPermission(...)->andIf(AccessResult::allowedIf(!$archived)), whereas "nobody, not even other modules, may delete an object on loan" is forbiddenIf($onLoan) because only forbidden beats the grants other modules add.

Why cacheability is part of the result

Access decisions are cached: inside the render cache of the block or page that displays a link, in the dynamic page cache and in the entity access handler’s per-request static cache. A cached decision is correct only for the situations it was computed in, so each result carries three kinds of metadata, merged automatically by orIf() and andIf():

Metadata Use it when Example

Cache contexts

The answer depends on the request or the user.

user.permissions, user.roles, user, languages:language_content. allowedIfHasPermission() adds user.permissions; cachePerPermissions() and cachePerUser() add them explicitly.

Cache tags

The answer depends on stored data.

addCacheableDependency($entity) or addCacheableDependency($config), so that saving the object or the setting invalidates the decision.

Max-age

The answer depends on time.

setCacheMaxAge($seconds). The embargo checker above uses it. Cache::PERMANENT (-1) means no limit; 0 means never cache.

Rules of thumb:

  • If the result depends on it, declare it. Reading a field of the entity, a configuration value or the current time, without declaring the matching tag, context or max-age, produces access that is correct for the first visitor and wrong for later ones. This is the origin of reports such as "the curator sees the button only after a cache clear".

  • Declare it on every branch. Return paths that exit early, such as a neutral result when the entity is of another bundle, still depend on whatever was read to decide that.

  • Contexts widen, they do not narrow. user is more specific than user.permissions; asking for user on a frequently shown element multiplies cache variations. Choose the narrowest context that is true.

  • allowedIfHasPermission() is not enough when other data matters. The curator access example in the routing page adds the entity as a cacheable dependency for exactly that reason.

  • Never use user.permissions from inside an access policy. The context is computed from the permissions the policy is building, which would recurse. Use user.roles or another context instead (see below).

  • Do not disable caching to make a bug go away. setCacheMaxAge(0) on an access result makes every page that contains it uncacheable in the dynamic page cache. Debug the missing metadata instead. The caching system itself is in Performance and Caching and Render API and Cache Metadata.

Entity access

Entity access answers "may this account perform this operation on this entity?" for the whole system: the _entity_access route requirement, the operation links of listings, Views, the REST and JSON:API resources, the entity forms and $entity->access('update') calls in code. The entity type definition names the handler class, and the handler usually extends EntityAccessControlHandler. The complete handler for the collection object is in Custom Entity Types; this section covers how its answer is assembled and how other code takes part.

flowchart TD A["entity access call: view, update, delete or a custom operation"] --> B["Static per-request cache hit?"] B -->|"yes"| Z["Return cached AccessResult"] B -->|"no"| H["Invoke hook_entity_access and hook_ENTITY_TYPE_access"] H --> C["Combine hook results with orIf"] C --> F{"Forbidden?"} F -->|"yes"| Z2["Result is forbidden, handler is skipped"] F -->|"no"| D["Add checkAccess result with orIf"] D --> Z3["Final AccessResult, cached for the request"]

The order has consequences:

  • Hooks run first and their results are combined with orIf(): any module can grant an operation by being allowed and any module can veto it by being forbidden. A neutral hook result changes nothing.

  • The handler’s checkAccess() is added with orIf() only when no hook forbade the operation, so a veto from another module cannot be overturned by your handler.

  • The handler’s default checkAccess() grants everything to holders of the entity type’s admin_permission, forbids delete on entities that are not yet saved, and otherwise returns neutral. Always call parent::checkAccess() first and return its result when it allows, or an administrator loses access you did not mean to remove.

  • Creation is separate: createAccess() calls hook_entity_create_access and hook_ENTITY_TYPE_create_access, then checkCreateAccess(), because there is no entity yet to ask about.

  • Revisionable entities add operations (view all revisions, view revision, revert revision, delete revision) and the handler forbids reverting or deleting the default revision on its own.

Implementing entity access hooks as classes

Hooks let another module change who may work with an entity it does not own. Since Drupal 11.1 they are methods on a class in src/Hook, marked with the #[Hook] attribute; see Hooks and OOP Hooks. The museum rule: an exhibition that is published cannot be deleted by editors, only unpublished first. Administrators keep the right. The same class also hides the sensitive valuation field.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;

/**
 * Access rules contributed by the Meridian collection module.
 */
final class AccessHooks {

  /**
   * Published exhibitions must be unpublished before they can be deleted.
   */
  #[Hook('node_access')]
  public function nodeAccess(NodeInterface $node, string $op, AccountInterface $account): AccessResultInterface {
    if ($node->bundle() !== 'exhibition' || $op !== 'delete') {
      return AccessResult::neutral();
    }
    $blocked = $node->isPublished() && !$account->hasPermission('administer nodes');
    return AccessResult::forbiddenIf($blocked, 'Unpublish the exhibition before deleting it.')
      ->addCacheableDependency($node)
      ->cachePerPermissions();
  }

  /**
   * Only holders of the valuation permission may see or edit the valuation.
   */
  #[Hook('entity_field_access')]
  public function fieldAccess(string $operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL): AccessResultInterface {
    if ($field_definition->getTargetEntityTypeId() !== 'collection_object' || $field_definition->getName() !== 'valuation') {
      return AccessResult::neutral();
    }
    // Fields are allowed by default, so withholding needs an explicit veto.
    return AccessResult::forbiddenIf(!$account->hasPermission('view object valuation'))
      ->cachePerPermissions();
  }

}

Notes on the example:

  • The hook names are the old function names without the hook_ prefix: node_access, entity_field_access, collection_object_access for the entity-specific variant of the museum’s own type. No .module code is needed and #[LegacyHook] is not required on Drupal 11.1 and later. A module that still supports Drupal 10 keeps the hook function in .module and wraps it with #[LegacyHook] as described in the hooks page.

  • The $items argument of the field hook is null when the question is about the field in general (for example a field-settings form), so the code must not assume it is set.

  • Field access is part of the entity API and therefore applies to forms, REST, JSON:API and the field’s formatters. A field that must be hidden belongs here and not only in a Twig template: Twig hides output, field access prevents the data from being exposed through the API at all.

  • Neutral from a field hook means "default", which for fields is allowed. A hook that wants to restrict must return forbidden, unlike entity-level hooks where neutral is the same as no.

Queries and listings

An entity query that returns entities for a user to read must say so explicitly. Drupal 10 removed the permissive default: calling execute() without accessCheck() throws an exception. Pick the value deliberately:

// Listing for a visitor: only what the current user may view.
$ids = $this->entityTypeManager->getStorage('collection_object')
  ->getQuery()
  ->accessCheck(TRUE)
  ->condition('on_display', 1)
  ->sort('accession_number')
  ->execute();

// A background job that processes every record regardless of who is logged in.
$ids = $this->entityTypeManager->getStorage('collection_object')
  ->getQuery()
  ->accessCheck(FALSE)
  ->condition('on_display', 1)
  ->execute();

accessCheck(TRUE) loads each result and runs the entity access handler (or joins the node_access table for nodes), so it is the only variant that respects the rules in this page. accessCheck(FALSE) is correct for cron, queue workers and migrations, and is a data leak anywhere its result reaches a response. Reading the database directly through $connection->select() bypasses entity access, field access and node grants completely; treat every such query as a security-sensitive code path. See Database API and Entity Queries.

Node grants

Node access has a database layer in addition to the handler: grants. A module that restricts nodes to groups of users writes records into the node_access table when a node is saved (hook_node_access_records()), and tells core which grants an account holds (hook_node_grants()). Core matches records and held grants when it checks a node operation and, for listings, when a query is tagged node_access. The design is the reason Views and search can filter thousands of nodes in one query, which a per-entity handler could not do.

The museum shares some exhibitions with partner museums. Each shared exhibition carries a field_loan_program value (a list of integers, one per programme) and each partner curator account carries a field_partner_program value. A partner may view only the exhibitions shared with its programme; museum curators view all of them; the general public views exhibitions that are not in any programme.

Three points drive the design:

  1. Once any module implements hook_node_grants(), core stops writing the default "everyone can view published nodes" record. Every node therefore needs a record, including nodes the module does not care about, or ordinary visitors lose access to them. The module writes a record in a realm for "open" nodes too.

  2. Grants are additive. A record can only widen access. If a node must stay hidden from some users, the realms they hold must not match any of its records. Another module’s grants for the same node still apply; to take a grant away use hook_node_access_records_alter().

  3. Records ignore the published status. The module has to put the published flag into grant_view itself, otherwise a draft becomes visible to everyone who holds the grant.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;

/**
 * Restricts shared exhibitions to the museum staff and the partner museum.
 */
final class LoanProgramGrants {

  /** Realm for nodes shared with a partner programme; the gid is the programme. */
  public const REALM_PROGRAM = 'meridian_loan';

  /** Realm for every other node; the gid is always 0. */
  public const REALM_OPEN = 'meridian_loan_open';

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
   * Writes the records for a node that is being saved.
   *
   * @return array<int, array<string, mixed>>
   */
  #[Hook('node_access_records')]
  public function records(NodeInterface $node): array {
    $published = (int) $node->isPublished();
    $programme = $node->hasField('field_loan_program') && !$node->get('field_loan_program')->isEmpty()
      ? (int) $node->get('field_loan_program')->value
      : 0;

    return [[
      'realm' => $programme > 0 ? self::REALM_PROGRAM : self::REALM_OPEN,
      'gid' => $programme,
      'grant_view' => $published,
      'grant_update' => 0,
      'grant_delete' => 0,
      'langcode' => $node->language()->getId(),
    ]];
  }

  /**
   * Tells core which grants this account holds for an operation.
   *
   * @return array<string, array<int, int>>
   */
  #[Hook('node_grants')]
  public function grants(AccountInterface $account, string $op): array {
    $grants = [self::REALM_OPEN => [0]];
    if ($op !== 'view') {
      return $grants;
    }
    if ($account->hasPermission('curate meridian collection')) {
      // Museum staff see every programme.
      $grants[self::REALM_PROGRAM] = [1, 2, 3];
    }
    elseif ($account->isAuthenticated()) {
      $user = $this->entityTypeManager->getStorage('user')->load($account->id());
      if ($user !== NULL && $user->hasField('field_partner_program') && !$user->get('field_partner_program')->isEmpty()) {
        $grants[self::REALM_PROGRAM] = [(int) $user->get('field_partner_program')->value];
      }
    }
    return $grants;
  }

}

Operational facts about grants:

  • Records are written on save. After installing the module, changing its rules or changing the field a rule reads, existing nodes still carry the old records. Drupal then reports that the content access permissions need to be rebuilt. The rebuild rewrites the whole node_access table in a batch, so run it in a quiet period and not on every deployment, only when the rules changed.

  • The rebuild API moved in 11.4. The procedural node_access_rebuild() and node_access_needs_rebuild() are deprecated in Drupal 11.4.0 and are removed in 13.0.0. Their replacement is the Drupal\node\NodeAccessRebuild service with rebuild(), needsRebuild() and setNeedsRebuild(). Code in an update function or a form should inject it (or resolve it by its class name) rather than call the old function. Older books, tutorials and contributed modules still show the procedural call.

  • Status report and Drush. The rebuild is also available from Reports > Status report and on the command line:

ddev drush php:eval "\Drupal::service(\Drupal\node\NodeAccessRebuild::class)->rebuild();"
ddev drush cache:rebuild
  • Node ID 0 records. A record for node ID 0 means "this realm and gid may view every node". The rebuild erases such records, so a module that relies on one must write it again after the rebuild.

  • The query tag. Queries with accessCheck(TRUE) and Views join node_access only for nodes. Other entity types have no grants layer and rely on their handler.

  • Bypass. The permission bypass node access and administrator roles skip grants. Do not give it to a role to "make the site work" while testing a grants module.

  • Moderation and workspaces. Content moderation does not use grants for its states; it uses transition permissions, see Content Moderation, Workflows and Revisions. A workspace changes which revision a user sees, not the grants, see Workspaces.

Grants are a heavy tool. They suit "this group of users sees this group of nodes" on a large site. For a handful of rules, a hook_node_access veto (above) or a dedicated entity type with its own handler is simpler and does not need rebuilds.

The Access Policy API

Before Drupal 10.3, "what permissions does this account hold?" was answered in one place: the union of the permissions of its roles, with user 1 as a special case. The Access Policy API, added in 10.3.0, turns that into a pipeline. An access policy is a service tagged access_policy that contributes permissions to, or revokes them from, an account for a scope. Core ships UserRolesAccessPolicy (role permissions and the is_admin flag) and SuperUserAccessPolicy (user 1, unless security.enable_super_user is false). A module adds its own to express rules that are not "has this role": time of year, the user’s department, the active domain, a feature flag.

sequenceDiagram participant Code as Caller participant PC as permission_checker participant AP as access_policy_processor participant Roles as UserRolesAccessPolicy participant Loan as LoanSeasonAccessPolicy Code->>PC: hasPermission manage object loans for the account PC->>AP: processAccessPolicies for the account AP->>Roles: calculatePermissions AP->>Loan: calculatePermissions AP->>Roles: alterPermissions AP->>Loan: alterPermissions AP-->>PC: immutable calculated permissions, cached per user PC-->>Code: true or false

The interface, Drupal\Core\Session\AccessPolicyInterface, has four methods; the abstract AccessPolicyBase implements sensible defaults:

Method Purpose

applies(string $scope): bool

Whether the policy takes part for the requested scope. AccessPolicyBase returns true only for AccessPolicyInterface::SCOPE_DRUPAL ('drupal'), the scope core uses for ordinary permissions. Policies of another scope are skipped, so they cannot change site-wide permissions.

calculatePermissions(AccountInterface $account, string $scope)

The build phase. Returns the permissions the policy grants as a RefinableCalculatedPermissionsInterface, built from CalculatedPermissionsItem objects. This is where permissions are added.

alterPermissions(AccountInterface $account, string $scope, RefinableCalculatedPermissionsInterface $calculated_permissions)

The alter phase, run after every policy has built its permissions. It exists to revoke permissions. The interface documentation says to write another policy using calculatePermissions() when you need to add.

getPersistentCacheContexts(): array

The cache contexts the policy’s calculation always varies by. They are used as the cache variation key for the calculated permissions of the account.

A CalculatedPermissionsItem is created with new CalculatedPermissionsItem(array $permissions, bool $isAdmin = FALSE, string $scope = 'drupal', string|int $identifier = 'drupal'). When $isAdmin is true the item stores no list and hasPermission() returns true for everything in that scope and identifier. The identifier allows one scope to have many instances: a contributed Domain module could use the scope domain and an identifier per domain, so that an editor is allowed to publish on one domain only. Core itself uses only the default scope and identifier.

A policy for curators

The museum rule: during the annual loan season (the loan_season flag in meridian_collection.settings), users with the curator role additionally hold manage object loans. This is not a role grant, so the permission does not appear on the role’s permission page, but hasPermission() returns true and the loan routes open. The shape of the policy, including its place in the site builder’s view, is described in Users, Roles and Permissions; here is the developer version.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Access;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;
use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;

/**
 * Grants curators the loan-management permission during the loan season.
 */
final class LoanSeasonAccessPolicy extends AccessPolicyBase {

  public function __construct(private readonly ConfigFactoryInterface $configFactory) {}

  /**
   * {@inheritdoc}
   */
  public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
    // AccessPolicyBase adds the persistent contexts to the new object.
    $calculated = parent::calculatePermissions($account, $scope);

    $settings = $this->configFactory->get('meridian_collection.settings');
    // Whenever the flag changes, the cached result is rebuilt.
    $calculated->addCacheableDependency($settings);

    if (!$settings->get('loan_season') || !in_array('curator', $account->getRoles(), TRUE)) {
      return $calculated;
    }
    return $calculated->addItem(new CalculatedPermissionsItem(['manage object loans']));
  }

  /**
   * {@inheritdoc}
   */
  public function getPersistentCacheContexts(): array {
    // The result varies by the roles of the account, never by user.permissions.
    return ['user.roles'];
  }

}
# meridian_collection.services.yml
services:
  meridian_collection.loan_season_access_policy:
    class: Drupal\meridian_collection\Access\LoanSeasonAccessPolicy
    autowire: true
    tags:
      - { name: access_policy }

The policy keys off the role id curator, which looks like the role check this page told you to avoid. The difference is that a policy exists to implement a rule about a role or attribute of the account when no permission can express it; the site-wide ability it grants is still a permission (manage object loans), declared in the YAML file and checked everywhere by name. Prefer an account field, a group or a configurable setting over a hard-coded role id when the museum can model it that way.

Revoking with the alter phase

The second museum rule: while the collections-management system is migrated, an administrator can set a freeze flag. During the freeze nobody may edit collection objects, not even holders of the permission through a role. A policy cannot "forbid" a permission in the build phase, because policies only add; it uses the alter phase to remove it.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Access;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;
use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;

/**
 * Removes the editing permissions while the collection is frozen.
 */
final class CollectionFreezeAccessPolicy extends AccessPolicyBase {

  private const REVOKED = ['edit collection objects', 'delete collection objects', 'create collection objects'];

  public function __construct(private readonly ConfigFactoryInterface $configFactory) {}

  /**
   * {@inheritdoc}
   */
  public function alterPermissions(AccountInterface $account, string $scope, RefinableCalculatedPermissionsInterface $calculated_permissions): void {
    $settings = $this->configFactory->get('meridian_collection.settings');
    // Declare the dependency even when the freeze is off, so switching it on is seen.
    $calculated_permissions->addCacheableDependency($settings);
    if (!$settings->get('collection_frozen')) {
      return;
    }

    foreach ($calculated_permissions->getItems() as $item) {
      if ($item->isAdmin() || $item->getScope() !== $scope) {
        // Administrators keep everything, otherwise nobody could end the freeze.
        continue;
      }
      $remaining = array_diff($item->getPermissions(), self::REVOKED);
      $calculated_permissions->addItem(
        new CalculatedPermissionsItem($remaining, FALSE, $item->getScope(), $item->getIdentifier()),
        TRUE,
      );
    }
  }

}

The second argument of addItem() overwrites the existing item for the same scope and identifier instead of merging into it. The service entry is the same as for the previous policy, with the new class and a different service id. The policy needs no getPersistentCacheContexts() override because the base class returns none and the result depends only on a configuration value, which the cacheable dependency covers.

Rules that keep policies safe and fast:

  1. Mark policy classes final. The documentation advises against extending another module’s policy because a policy takes part in both phases. To change a core or contributed policy, decorate the service or replace it in a service provider.

  2. Declare every dependency. Contexts that always apply go in getPersistentCacheContexts(); data-dependent ones (configuration, entities) are added with addCacheableDependency() on the calculated object. Forgetting one keeps stale permissions in the cache of an account. The processor tags every cached result with access_policies, so a policy that changes its own rules can invalidate them with that tag.

  3. Do not use user.permissions as a context. It is computed from the result and would recurse.

  4. Be cheap. The calculation runs once per account and scope and is cached, but a slow policy delays the first request of every session after a cache clear. Avoid queries; read configuration, the account object and values already loaded.

  5. Use the account you receive. The processor may calculate permissions for an account other than the current user (for example when an administrator previews, or a queue acts on behalf of a user). \Drupal::currentUser() inside a policy would give the wrong person’s rights.

  6. Keep one concern per policy. Several small policies are easier to test than one policy with branches.

  7. Permissions are immutable once built. Application code cannot add a permission in the middle of a request; to change the outcome, change the inputs and let the cache invalidate.

Testing access

Access code is easy to test and expensive to get wrong, so test each layer. A kernel test is enough for permissions and policies; use a functional test (BrowserTestBase) for a route with a rendered denial page.

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\KernelTests\KernelTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\Entity\User;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests the seasonal loan permission and the collection freeze.
 */
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class AccessPolicyTest extends KernelTestBase {

  protected static $modules = ['system', 'user', 'meridian_collection'];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('user');
    $this->installEntitySchema('collection_object');
    $this->installConfig(['meridian_collection']);
    Role::create(['id' => 'curator', 'label' => 'Curator', 'permissions' => ['edit collection objects']])->save();
  }

  public function testLoanSeasonGrantsLoanPermission(): void {
    $curator = User::create(['name' => 'curator1', 'status' => 1, 'roles' => ['curator']]);
    $curator->save();

    $this->assertFalse($curator->hasPermission('manage object loans'));

    $this->config('meridian_collection.settings')->set('loan_season', TRUE)->save();
    $this->assertTrue($curator->hasPermission('manage object loans'));
  }

  public function testFreezeRevokesEditing(): void {
    $curator = User::create(['name' => 'curator2', 'status' => 1, 'roles' => ['curator']]);
    $curator->save();
    $this->assertTrue($curator->hasPermission('edit collection objects'));

    $this->config('meridian_collection.settings')->set('collection_frozen', TRUE)->save();
    $this->assertFalse($curator->hasPermission('edit collection objects'));
  }

}
ddev exec vendor/bin/phpunit -c web/core web/modules/custom/meridian_collection/tests/src/Kernel

The tests assert the change of an outcome after a configuration save. That is what proves the cacheable dependency is declared; a policy that forgets it would pass a single-assertion test and fail this one. PHPUnit 11 uses attributes (#[Group]) instead of the old @group annotations. Test the entity handler and the hooks the same way: create users with and without each permission, then assert $entity->access('update', $user), field access with $entity->get('valuation')->access('view', $user), and the status code of the protected routes. More on the test types is in Automated Testing.

Security checklist

  • Deny by default. Return neutral when a rule does not apply and let the system say no. Use forbidden only for explicit vetoes.

  • Enforce on the server, at the layer that owns the data. Hiding a link, a form element or a Twig variable is a usability feature. The route, entity, field or query must also refuse.

  • Check the right account. In a hook or a service use the $account argument. A cron job, a queue worker or an API call may run as a different user than the one in the session.

  • Declare cacheability. Missing contexts and tags are how one user’s decision is served to another. When in doubt, prefer more specific metadata over max-age 0.

  • Use restrict access: true on permissions that allow privilege escalation, raw markup, configuration changes or other people’s data, and never grant them to the Authenticated user role.

  • Never expose state changes on a plain GET link. Add _csrf_token or use a form.

  • accessCheck(FALSE) is for batch work. Every use needs a reason in a comment, and its output must not be sent to a user.

  • Private files follow entity access. A private file is served only if some hook_file_download implementation allows it; the file system page shows how to tie it to an entity access check, see File System and Private Files.

  • APIs obey the same handlers. JSON:API and REST apply entity and field access, so a rule written once protects the headless kiosk as well, see JSON:API.

  • Review user 1 and administrator roles. Disable the super-user access policy in production with the security.enable_super_user container parameter where the site does not need it, and keep the number of accounts with administer permissions small. The wider hardening list is in Security Hardening.

Common errors and pitfalls

Symptom Likely cause Fix

A route returns 403 for everyone, administrators included.

The requirements section is empty or the key has no checker, so every checker is neutral.

Add _permission or _access: 'TRUE'. Check the key spelling and that a custom access_check service has the right applies_to.

The curator sees a button only after a cache clear.

An access result missing a cache context, tag or max-age.

Add cachePerPermissions() or allowedIfHasPermission(), addCacheableDependency($entity) and setCacheMaxAge() for time.

A new permission does not appear on the permission page.

Cache not rebuilt, YAML indentation error or key not under the module’s file.

ddev drush cache:rebuild; validate the YAML; the file must be called MODULE.permissions.yml.

A dynamic permission survives after its content type was deleted.

Generated without a config dependency.

Use BundlePermissionHandlerTrait or add dependencies: config: [...] yourself.

An entity query throws about missing access checking.

execute() without accessCheck().

Call accessCheck(TRUE) for user-facing data, FALSE for system work.

Visitors cannot see any content after enabling a node-grants module.

The module wrote records only for some nodes and the default "all" record is no longer written.

Write a record for every node, as the open realm above does, and rebuild the node access table.

Unpublished content is visible to the partner group.

grant_view set to 1 regardless of status.

Use (int) $node->isPublished().

A veto from another module has no effect.

The result was neutral, not forbidden, or the handler’s allowed result was combined with orIf() after no veto.

Return AccessResult::forbidden() where the rule is final.

A policy-granted permission works for one user and not another.

Missing cache context in the policy.

Return the real contexts in getPersistentCacheContexts() and add data dependencies on the calculated object.

hasPermission() of a policy recurses or times out.

user.permissions used as a context inside a policy.

Use user.roles or another context.

node_access_rebuild() is reported as deprecated.

The function is deprecated in 11.4.0 and removed in 13.0.0.

Inject Drupal\node\NodeAccessRebuild.

Version notes and corrections of older material

  • Access Policy API (10.3). Permissions were computed from roles only before 10.3.0. Tutorials and books written for earlier releases describe hasPermission() as a role lookup and know nothing about policies; Drupal 10 Development Cookbook (2023) targets Drupal 10.0, which predates the API. On 10.3 and later the role lookup is only one policy.

  • Hooks as classes (11.1). Older books such as Drupal 10 Development Cookbook (2023) show every access hook as a function in .module. Since 11.1 the #[Hook] attribute on an autowired class in src/Hook is the standard form; ordering and removal attributes arrived in 11.2. hook_install, hook_schema, hook_requirements, hook_update_N and hook_post_update_NAME stay procedural, but the access hooks on this page do not.

  • Annotations and static calls. The same book shows plugin and entity-type annotations such as @ContentEntityType, and \Drupal::currentUser() or \Drupal::service() calls inside classes. Plugins use PHP attributes since 10.2, and classes receive services through constructors (core aliases and #[Autowire] since 10.1). \Drupal:: belongs in procedural code and tests only.

  • Entity queries and accessCheck(). Calling execute() without accessCheck() throws an exception since Drupal 10, and in 12 query objects are no longer serialisable.

  • Node access rebuild API (11.4). node_access_rebuild() and node_access_needs_rebuild() are deprecated in 11.4.0 and removed in 13.0.0 in favour of the NodeAccessRebuild service. Tutorials still use the function.

  • Node published status (11.3). The permission "Administer node published status" is separate from editing; the site builder view is in Users, Roles and Permissions.

  • Drupal 10 end of life. Drupal 10 ends on 2026-12-09, so new access code targets Drupal 11. Drupal 12 is scheduled for the week of 2026-12-07 and requires PHP 8.5, Symfony 8 and PHPUnit 12.5; the APIs here are unchanged in principle, but run static analysis with the Drupal deprecation rules (see Preparing for Drupal 12) before upgrading.

  • Older tutorials and Drupal 7. hook_permission() and user_access() belong to Drupal 7. Their replacements are the YAML file and hasPermission(). Other outdated advice is collected in Where Older Books and Tutorials Are Outdated.