Field-Level Access Control in Drupal with hook_entity_field_access()for Drupal 11 , and 10

Last updated :  

Every field you've built so far in this topic has been visible to anyone who could view the entity it's attached to. But what if you wanted a field that only certain roles could see — an internal note, a sensitive detail, something a contributor shouldn't be able to read on someone else's content but should be able to read on their own? That's exactly what this lesson's module does, and it does it entirely through Drupal's Field API access system, with no custom access manager or complicated node grants required.

What you'll learn in this lesson

  • How to declare a set of granular, "own vs any" permissions in a .permissions.yml file
  • How hook_entity_field_access() gets called for every field on every entity, and why that means your first job inside it is almost always "is this even my field?"
  • Why Drupal uses an AccessResult object instead of a plain true/false, and what neutral() actually means
  • How to check "does this user own this entity" when the field might be attached to a node, a user, or anything else
Why this matters beyond one field: the "own vs any" permission pattern here is the exact same one Drupal core uses for content (edit own content / edit any content). Once you understand it here, you'll recognize it everywhere in Drupal's permission system.

The source file

Path: modules/field_permission_example/field_permission_example.module

<?php

/**
 * @file
 * An example field using the Field Types API.
 */

/**
 * @defgroup field_permission_example Example: Field Permissions
 * @ingroup examples
 * @{
 * Example using permissions on a Field API field.
 *
 * This example is a relatively simple text field you can attach to any
 * fieldable entity.
 *
 * In this module we demonstrate how to limit access to a field. Drupal's Field
 * API gives you two operations to permit or restrict: view and edit. So you can
 * then decide who gets to see fields, who can edit them, and who can manage
 * them.
 *
 * Our field is called field_permission_example_field_note. It has a simple
 * default widget of a text area, and a default formatter that applies a CSS
 * style to make it look like a sticky note.
 *
 * In addition to demonstrating how to set up permissions-based access to a
 * field, this module also demonstrates the absolute minimum required to
 * implement a field, since it doesn't have any field settings.
 *
 * If you wish to use this code as skeleton code for a field without
 * permissions, you can simply omit field_permission_example.permissions.yml and
 * remove field_permission_example_entity_field_access().  In addition, our call
 * to field_permission_example_theme() is used to set up our description page
 * for the example, which you don't  need for a working field.
 *
 * How does it work?
 *
 * You can install this module and go to path /examples/field_permission_example
 * for an introduction on how to use this field.
 *
 * OK, how does the code work?
 *
 * As with any permission system, we create a MODULE_NAME.permissions.yml file
 * in order to define a few permissions. In our case, users will want to either
 * view or edit field_note fields. Similarly to how node permissions work,
 * we'll also include a context of either their own content or any content. This
 * gives us four permissions which administrators can assign to various roles.
 * See field_permission_example.permissions.yml for the list.
 *
 * With our permissions defined in the YAML file, we can now handle requests for
 * access. Those come in through hook_entity_field_access(), which we've
 * implemented as field_permission_example_entity_field_access(). This function
 * determines whether the user has the ability to view or edit the field in
 * question by calling $account->hasPermission(). We also give special edit
 * access to users with the 'bypass node access', 'administer content types'
 * permissions, defined by the node module, and the
 * "administer the field note field" permission we define for the module.
 *
 * One tricky part is that our field won't always be attached to nodes. It could
 * be attached to any type of entity. Fortunately, most content entities
 * implement EntityOwnerInterface, which gives us a way to check this. An
 * exception to this is the User entity; here, we just check to see that the
 * account name matches that of $account.  We can get the entity itself by
 * calling $items->getEntity(), since these "know" what entity they belong to.
 *
 * In a real application, we'd have use-case specific permissions which might be
 * more complex than these. Or perhaps simpler.
 *
 * You can see a more complex field implementation in field_example.module.
 *
 * @see field_example
 * @see field_example.module
 * @see field_types
 * @see field
 */

// Use statements to support hook_entity_field_access.
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Access\AccessResult;

// Interfaces used by entities to declare "ownership".
use Drupal\user\EntityOwnerInterface;
use Drupal\user\UserInterface;

// Use statements for hook_entity_test_access.
use Drupal\Core\Entity\EntityInterface;

/**
 * Implements hook_entity_field_access().
 *
 * We want to make sure that fields aren't being seen or edited
 * by those who shouldn't.
 */
function field_permission_example_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {
  $messenger = \Drupal::messenger();
  // Find out what field we're looking at.  If it isn't
  // our sticky note widget, tell Drupal we don't care about its access.
  if ($field_definition->getType() != 'field_permission_example_field_note') {
    return AccessResult::neutral();
  }

  // First we'll check if the user has the 'superuser'
  // permissions that node provides. This way administrators
  // will be able to administer the content types.
  if ($account->hasPermission('bypass node access')) {
    $messenger->addMessage(t('User can bypass node access.'));
    return AccessResult::allowed();
  }
  if ($account->hasPermission('administer content types', $account)) {
    $messenger->addMessage(t('User can administer content types.'));
    return AccessResult::allowed();
  }
  if ($account->hasPermission('administer the field note field', $account)) {
    $messenger->addMessage(t('User can administer this field.'));
    return AccessResult::allowed();
  }

  // For anyone else, it depends on the desired operation.
  if ($operation == 'view' and $account->hasPermission('view any field note')) {
    $messenger->addMessage(t('User can view any field note.'));
    return AccessResult::allowed();
  }

  if ($operation == 'edit' and $account->hasPermission('edit any field note')) {
    $messenger->addMessage(t('User can edit any field note.'));
    return AccessResult::allowed();
  }

  // At this point, we need to know if the user "owns" the entity we're attached
  // to. If it's a user, we'll use the account name to test. Otherwise rely on
  // the entity implementing the EntityOwnerInterface. Anything else can't be
  // owned, and we'll refuse access.
  if ($items) {
    $entity = $items->getEntity();
    if ((($entity instanceof EntityOwnerInterface) and
         $entity->getOwner()->getAccountName() == $account->getAccountName()) or
        (($entity instanceof UserInterface) and
         $entity->name->value == $account->getAccountName())
        ) {
      if ($operation == 'view' and $account->hasPermission('view own field note')) {
        $messenger->addMessage(t('User can view their own field note.'));
        return AccessResult::allowed();
      }
      if ($operation == 'edit' and $account->hasPermission('edit own field note')) {
        $messenger->addMessage(t('User can edit their own field note.'));
        return AccessResult::allowed();
      }
    }
  }
  // Anything else on this field is forbidden.
  return AccessResult::forbidden();
}

/**
 * Implements hook_ENTITY_TYPE_access().
 *
 * Note: this routine is added so we can more easily test our access code. Core
 * defines an entity_test entity that is used for testing fields in core. We add
 * this routine to make the entity_test entity editable by our tests.
 */
function field_permission_example_entity_test_access(EntityInterface $entity, $operation, AccountInterface $account, $langcode) {
  if ($operation == 'edit') {
    $perms = [
      'administer the field note field',
      'edit any field note',
      'edit own field note',
    ];
    foreach ($perms as $perm) {
      if ($account->hasPermission($perm)) {
        return AccessResult::allowed();
      }
    }
  }
  return AccessResult::neutral();
}

/**
 * @} End of "defgroup field_permission_example".
 */

/**
 * Implements hook_theme().
 *
 * Since we have a lot to explain, we're going to use Twig to do it.
 */
function field_permission_example_theme() {
  return [
    'field_permission_description' => [
      'template' => 'description',
      'variables' => [
        'admin_link' => NULL,
      ],
    ],
  ];
}

How it works

Declaring the permissions in YAML

'view own field note':
  title: View own field note
'edit own field note':
  title: Edit own field note
'view any field note':
  title: View any field note
'edit any field note':
  title: Edit any field note
'administer the field note field':
  title: Administer settings for the field note field.

Five permissions from field_permission_example.permissions.yml, and the pattern is deliberate: an "own vs any" split for both operations (view/edit), plus one super-permission scoped just to this field. This gives an administrator real flexibility — a Contributor role might only get edit own field note, while an Editor role gets edit any field note. This is precisely the same shape as Drupal core's edit own content / edit any content node permissions — recognizing this pattern once means you'll spot it (and reuse it) constantly.

The hook that enforces all of it: hook_entity_field_access()

function field_permission_example_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {

This hook is Drupal's single integration point for field-level access control, and it's important to understand just how often it fires: Drupal calls this for every field on every entity, every time access needs to be checked. That includes fields your module has never heard of. The four parameters:

  • $operation — either 'view' or 'edit'.
  • $field_definition — describes the field's type, label, settings.
  • $account — the user whose access is being evaluated.
  • $items — the actual field values on a specific entity, or NULL when there's no entity context yet (like building a fresh add-form).

The most important line in the whole function: the early return

if ($field_definition->getType() != 'field_permission_example_field_note') {
  return AccessResult::neutral();
}
If you remember only one thing from this lesson, make it this: because this hook runs for every field on the site, forgetting this guard means your module starts silently interfering with access to fields it has nothing to do with. AccessResult::neutral() means "I have no opinion — let some other module or Drupal core decide." It is not the same as forbidden(). Getting this backwards is one of the most common ways a field access hook breaks an entire site.

Superuser bypass checks — reusing permissions instead of reinventing them

if ($account->hasPermission('bypass node access')) {
  return AccessResult::allowed();
}
if ($account->hasPermission('administer content types', $account)) {
  return AccessResult::allowed();
}
if ($account->hasPermission('administer the field note field', $account)) {
  return AccessResult::allowed();
}

Rather than inventing a brand-new "super admin" permission from scratch, the module checks two permissions the core Node module already defines (bypass node access, administer content types) before falling back to its own field-specific admin permission. This is good practice: it means administrators who already have broad site-management permissions don't need yet another permission checkbox assigned to them just to see every field on the site.

"Any" permissions — access regardless of ownership

if ($operation == 'view' and $account->hasPermission('view any field note')) {
  return AccessResult::allowed();
}
if ($operation == 'edit' and $account->hasPermission('edit any field note')) {
  return AccessResult::allowed();
}

Notice each check is paired with the matching $operation value — a user with only view any field note shouldn't be able to edit, so the hook checks both the permission and which operation is actually being requested.

The tricky part: figuring out who "owns" an entity you don't control the type of

if ($items) {
  $entity = $items->getEntity();
  if ((($entity instanceof EntityOwnerInterface) and
       $entity->getOwner()->getAccountName() == $account->getAccountName()) or
      (($entity instanceof UserInterface) and
       $entity->name->value == $account->getAccountName())
      ) {
    if ($operation == 'view' and $account->hasPermission('view own field note')) {
      return AccessResult::allowed();
    }
    if ($operation == 'edit' and $account->hasPermission('edit own field note')) {
      return AccessResult::allowed();
    }
  }
}

This field can be attached to literally any fieldable entity type — a node, a taxonomy term, a user — and "who owns this?" doesn't have one universal answer across all of them. So the hook handles it two ways:

  • Most entities (nodes, comments, media) implement EntityOwnerInterface, which exposes a clean getOwner() method. The hook compares the owner's account name to the current user's.
  • User entities are special — a user doesn't have an "owner" the way a node does, they are the account. So the fallback branch compares the entity's own name field directly against the current account's name.

The entity itself comes from $items->getEntity() — field item lists always know which entity they belong to. And note the outer if ($items) guard: when there's no entity context (like an empty add-form), this whole block is skipped, and execution falls through to the final forbidden().

Why AccessResult objects instead of true/false

Three factory methods appear throughout this hook:

  • AccessResult::neutral() — "no opinion, keep checking."
  • AccessResult::allowed() — explicitly grants access.
  • AccessResult::forbidden() — explicitly denies access, and (unlike a neutral result) can't be overridden by another module voting differently.

Drupal uses this three-state object instead of a plain boolean because access decisions in Drupal are collaborative — multiple modules can implement the same hook and each gets a say. A boolean can't represent "I don't have an opinion"; an object can. The function ending on AccessResult::forbidden() as its final fallback is a deliberate secure-by-default stance: unless something explicitly said yes, the answer is no.

Quick check: a user has the view own field note permission, but not view any field note. They open someone else's content that has this field on it. What does the hook return? (Answer: AccessResult::forbidden() — the "any" check fails, the ownership check fails since they don't own that entity, and nothing else in the hook grants access.)

The supporting hooks

field_permission_example_entity_test_access() exists purely to support Drupal's own automated testing — core's entity_test entity type has no built-in edit access, so this small hook unlocks it for the field's own test suite. field_permission_example_theme() registers a Twig template used only for this module's own explanatory landing page — it has nothing to do with the access-control logic itself.

See it for yourself

Visit /admin/people/permissions on your DDEV site and look for the "Field Permission Example" section.

The Field Permission Example section on the Drupal permissions admin page, showing five field-level permission checkboxes

Five checkboxes-per-role, exactly matching the five keys in the .permissions.yml file you just read: administer settings, edit any/own, view any/own. Every one of those checkboxes maps directly to a hasPermission() call inside hook_entity_field_access().

Key takeaways

  • Define field-level permissions in MODULE_NAME.permissions.yml using an "own vs any" split — the same pattern Drupal core uses for node permissions — to give administrators fine-grained role assignment.
  • Implement hook_entity_field_access() to enforce those permissions, and always return AccessResult::neutral() immediately for field types your module doesn't own — this hook runs for every field on the site, so skipping this guard breaks access to fields that aren't even yours.
  • Use AccessResult::allowed(), AccessResult::neutral(), and AccessResult::forbidden() — never plain booleans — so the Field API's collaborative access-checking works correctly when multiple modules weigh in.
  • Check ownership polymorphically: use EntityOwnerInterface::getOwner() for most entities, and fall back to a direct username comparison for UserInterface, since user entities are their own owner.
  • Reuse existing core superuser permissions (bypass node access, administer content types) before inventing new ones, so administrators aren't stuck assigning an ever-growing pile of module-specific permissions.
  • Guard against $items being NULL before calling $items->getEntity() — the hook fires without an entity context in some scenarios, and calling a method on NULL is a fatal error.

Coming up next

That's the whole Field API topic — field type, widget, formatter, and now access control. Next up is a completely different kind of extension point: Drupal's Event system, where instead of reacting to a single field, your code can react to almost anything happening anywhere on the site.