Skip to content

ConceptFor module developersDrupal 11

Access: users, roles, and permissions

Every route, entity operation, form element, and rendered block in Drupal passes through an access decision. This page explains the model behind those decisions, from accounts and permissions to access result objects, so your own checks are correct, cacheable, and consistent with core.

Authorization questions (“may this user edit this node?”) get asked thousands of times per request, by code that knows nothing about your site’s editorial structure. Drupal’s answer is a layered model:

  • Accounts identify who is acting. Anonymous visitors get an implicit account.
  • Roles group accounts: anonymous and authenticated are built in; sites define the rest.
  • Permissions are the named capabilities modules define (administer nodes, view event rsvp reports). Roles are granted permissions; code checks permissions.

The crucial property: code checks permissions, never roles. A module asks “does this account have delete any article content?” and stays agnostic about which roles a particular site wired that to. That indirection is what lets the same module work on a two-role blog and a fifty-role intranet.

Because access decisions end up cached inside pages and render arrays, every decision also has to say what it depends on. That is why access checks in Drupal return value objects carrying cacheability metadata instead of booleans.

Read the system as a pipeline with three stations:

  1. Policies calculate permissions. When something first asks about an account’s permissions, the \Drupal\Core\Session\AccessPolicyProcessor runs every registered access policy and merges the results into the account’s calculated permission set, cached per the contexts the policies declare.
  2. Checks consult permissions. Route requirements, entity access handlers, and your own code ask \Drupal\Core\Session\AccountInterface::hasPermission() or build richer checks from it.
  3. Results combine and bubble. Each check returns an \Drupal\Core\Access\AccessResult; results from multiple sources combine by explicit rules, and the final object’s cacheability bubbles into whatever rendered output depended on it.
From account to access result

An account's permissions are calculated by the access policy processor running every access policy. A check consults those permissions through hasPermission and returns an access result that is allowed, neutral, or forbidden, not a plain TRUE or FALSE. When results combine, forbidden beats both, allowed beats neutral, and if every check is neutral access is denied.

Modules declare permissions in a MODULE.permissions.yml file; entries are static YAML, with an escape hatch to a callback class for “one permission per X” cases driven by configuration:

web/modules/custom/docs_snippets/docs_snippets.permissions.yml
# Static permissions are plain YAML entries.
view docs snippets demos:
title: 'View docs snippets demo pages'
# Dynamic permissions come from a callback that returns one entry per
# "thing" — here, one permission per content type.
permission_callbacks:
- Drupal\docs_snippets\DocsSnippetsPermissions::contentTypePermissions

Mark permissions with security consequences with restrict access: true, which warns administrators before they grant them. The site builder wires permissions to roles at Administer → People → Permissions/admin/people/permissions; roles themselves are config entities (user.role.*), so the wiring deploys with your configuration.

Access results: allowed, neutral, forbidden

Section titled “Access results: allowed, neutral, forbidden”

An access check answers with one of three values, not two:

  • Allowed: “I checked, and this is permitted.”
  • Neutral: “I have no opinion.” If every checker stays neutral, access is denied; nothing is permitted by default.
  • Forbidden: “This must not happen, no matter what anyone else says.” Forbidden is a veto: combined with any other result it stays forbidden.

Results combine with orIf() (allowed wins over neutral, forbidden beats both) and andIf() (everything must allow). The static constructors mirror how you think: AccessResult::allowedIf($condition), AccessResult::allowedIfHasPermission($account, 'rsvp to events'), AccessResult::forbiddenIf($condition, $reason).

Every result is also a cacheable object. allowedIfHasPermission() adds the user.permissions cache context for you; anything else you consult, you declare. A custom route access checker from the tutorial shows the full shape, depending on both a config object and the node from the route:

web/modules/custom/event_rsvp/src/Access/RsvpAccessCheck.php
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\node\NodeInterface;
/**
* Grants access to RSVP routes only on RSVP-enabled content types.
*
* Registered in event_rsvp.services.yml with the "access_check" tag and
* applies_to: _event_rsvp_enabled, which routes opt into via their
* requirements section.
*/
class RsvpAccessCheck implements AccessInterface {
/**
* Constructs the access check.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
*/
public function __construct(
protected readonly ConfigFactoryInterface $configFactory,
) {}
/**
* Checks that the node's content type accepts RSVPs.
*
* @param \Drupal\node\NodeInterface $node
* The node from the route.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result, with cacheability metadata attached.
*/
public function access(NodeInterface $node): AccessResultInterface {
$settings = $this->configFactory->get('event_rsvp.settings');
$enabled_types = $settings->get('enabled_types') ?? [];
return AccessResult::allowedIf(in_array($node->bundle(), $enabled_types, TRUE))
// The result is only valid as long as the settings and the node stay
// the same; declaring that here keeps cached access decisions correct.
->addCacheableDependency($settings)
->addCacheableDependency($node);
}
}

Forgetting this metadata does not produce an error. It produces a page that shows one user’s access decision to a different user, once the result is cached. Treat the cacheability as part of the return value, exactly as on the caching page.

  • Routes declare requirements in their routing YAML: _permission: 'view event rsvp reports' for the standard case, _custom_access or a tagged access-check service (like the one above) for logic of your own. See Routes and controllers.
  • Entities route every create, view, update, and delete operation through their access control handler (\Drupal\Core\Entity\EntityAccessControlHandler by default). Always ask the entity, $node->access('update', $account), instead of guessing at permissions; the handler folds in bundle-specific permissions, ownership rules, and module opinions from hook_entity_access() and hook_ENTITY_TYPE_access().
  • Fields can differ per operation too (you may view a user’s name but not edit it); field widgets and formatters honor field access automatically.
  • Render arrays and form elements carry #access, which accepts an access result object so the decision’s cacheability travels with the output.

Permissions do not have to come from roles. The set of permissions an account holds is produced by access policies: services tagged access_policy implementing \Drupal\Core\Session\AccessPolicyInterface, each contributing or revoking permissions, with cacheability declared per policy. The \Drupal\Core\Session\AccessPolicyProcessor runs them in two phases (calculate, then alter) and caches the merged result in a variation cache.

Core ships two policies:

  • \Drupal\Core\Session\UserRolesAccessPolicy loads the account’s roles and contributes each role’s permissions, varying by user.roles. This is the classic role system, now expressed as one policy among possible many.
  • \Drupal\Core\Session\SuperUserAccessPolicy grants user 1 everything. It is controlled by the container parameter security.enable_super_user; set it to false in your site’s services.yml to make user 1 an ordinary account, which hardened sites should.
Permission check, not role check

Your code asks whether an account has a named permission, not whether it has a role. Behind that question the user roles access policy loads the account's roles and contributes each role's permissions to the calculated set. Checking the role directly skips this mapping and breaks on a site that wires the permission to a different role.

Your module can add policies of its own, granting capabilities from any source: membership in a group, a setting in config, or a field on the account. This one grants a module-defined permission to accounts with an on-call flag:

web/modules/custom/docs_snippets/src/ConceptsSystemsOnCallPolicy.php
<?php
declare(strict_types=1);
namespace Drupal\docs_snippets;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccessPolicyBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\CalculatedPermissionsItem;
use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;
/**
* Grants an extra permission to accounts flagged as on call.
*
* Register the class as a service tagged with access_policy and the
* processor folds its result into every permission check, next to the
* role-based policy that core ships. No role assignment involved.
*/
final class ConceptsSystemsOnCallPolicy extends AccessPolicyBase {
public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) {}
/**
* {@inheritdoc}
*/
public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
$calculated_permissions = parent::calculatePermissions($account, $scope);
/** @var \Drupal\user\UserInterface|null $user */
$user = $this->entityTypeManager->getStorage('user')->load($account->id());
if ($user === NULL) {
return $calculated_permissions;
}
// An editor toggles the boolean field_on_call on the account form.
if ($user->hasField('field_on_call') && $user->get('field_on_call')->value) {
$calculated_permissions->addItem(new CalculatedPermissionsItem(['respond to incidents']));
}
// Saving the account invalidates its user:ID tag, which drops the
// cached permission set when the flag changes.
$calculated_permissions->addCacheableDependency($user);
return $calculated_permissions;
}
/**
* {@inheritdoc}
*/
public function getPersistentCacheContexts(): array {
// The flag is stored per account, so the result differs per user, not
// per role. Varying by "user" is correct here: this cache holds one
// small permission set per account, not a rendered page per account.
return ['user'];
}
}

Extend \Drupal\Core\Session\AccessPolicyBase and declare the cache contexts your calculation varies by; the processor handles merging, caching, and invalidation (everything it caches carries the access_policies tag). Use alterPermissions() only to take permissions away after all policies have contributed.

Permission checks answer one entity at a time. Listings have a different problem: a query for “the 10 newest nodes this user may view” cannot load every node and ask each one. The node module solves this with grants, a materialized access index:

  • When a node is saved, modules answer hook_node_access_records() with the access records for that node, phrased in realms and grant IDs (realm workgroup, grant ID 5 means “workgroup 5 may view this node”).
  • When a user runs a query, modules answer hook_node_grants() with the grants that user holds per realm.
  • Node queries tagged node_access (which entity queries and Views produce automatically) join against the stored records, so filtering happens in SQL.

Grant-based access varies by the user.node_grants:OPERATION cache context, which the node module provides. Implement grants only when per-node visibility in listings is genuinely your requirement; for “who may use this feature” questions, permissions are simpler and cheaper. The full protocol, including rebuild semantics and the priority rules, is documented in the node access topic.

  • Roles are config entities you can manipulate in code. Tests and install code call grantPermission() on a loaded Role entity and save it; deployments do the same through exported user.role.* YAML.
  • allowedIfHasPermission() is everywhere. Search core for it and you find the canonical pattern: a one-line check that arrives pre-loaded with the right cache context, including its use inside block and entity access handlers.
  • The node module is the reference implementation of layered access. NodeAccessControlHandler combines the bypass node access permission, ownership-aware bundle permissions (edit own article content), module opinions from the access hooks, and the grants system, all expressed as combined access results.
  • The permission check itself is policy-driven. When hasPermission() runs, the answer comes from the calculated permission set the \Drupal\Core\Session\AccessPolicyProcessor cached, built by the two core policies plus any a module added. Role checks never happen at decision time.
  • “Check whether the user has the editor role.” Role names are site-specific wiring. Code that tests for a role breaks on every site that organizes roles differently and silently bypasses access policies. Define a permission, check the permission, and let sites decide which roles get it.

  • “Access checks return TRUE or FALSE.” They return allowed, neutral, or forbidden, and the difference matters when results combine. Neutral means “not my call” and still ends in denial if nobody allows. Forbidden is a veto that survives any combination, so reserve it for “this must never happen”, not for “I am not allowing this”.

  • “My access logic ran, so the result is current.” Access results are cached with the output they gated. If your check reads configuration, an entity, or the time of day and declares none of it, the first cached copy becomes everyone’s answer. Every input to the decision must appear in the result’s cacheability metadata.

  • “User 1 is magic, so I’ll test with it.” User 1’s bypass is one access policy, and sites can switch it off with the security.enable_super_user container parameter. Testing as user 1 tests nothing about your access logic; test with accounts holding the actual permissions.

  • “Permissions equal roles.” Roles are the most common source of permissions, not the definition of them. The Access Policy API grants and revokes permissions from any source, which is also why you should never reimplement “is user in role X” logic when a policy or permission expresses the intent directly.

  • “I implemented hook_ENTITY_TYPE_access, so my private nodes are hidden.” Entity access hooks run when an operation is checked on one entity. Listings filter through the node grants system instead, and a node invisible to $node->access('view') can still appear in a view if no grants exclude it. If listing visibility is the requirement, implement grants records, not only hooks.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.