Securing REST Resources: Permissions and Access Checksfor Drupal 8 , 9 , 10 , and 11

Last updated :  

An endpoint that responds to requests isn't automatically a secure one. The previous lesson covered which authentication providers a resource accepts — this one is about what happens after someone authenticates: whether they're actually allowed to call this specific resource, and the concrete mistakes that leave a "configured" REST endpoint quietly wide open to anyone.

What you'll learn in this lesson

  • The difference between authentication (who are you?) and authorization (what are you allowed to do?)
  • How to gate a REST resource with a real permission, not just an authentication requirement
  • How to add a custom, per-request access check for rules a static permission can't express
  • The most common way custom REST resources end up accidentally public

Authentication vs. authorization — two separate questions

Configuring supported_auth: [cookie, basic_auth] on a resource answers "how does a caller prove who they are?" It says nothing about whether that caller — once identified — is actually permitted to use this endpoint. Those are two independent layers, and skipping the second one is the single most common way a custom REST resource ends up leaking data: it correctly requires authentication, but grants access to any authenticated user, including a brand-new account with no special privileges.

Gating a resource with a real permission

The straightforward fix is a dedicated permission, declared in your module's *.permissions.yml, then checked from the resource plugin's access() method (available on ResourceBase, overridable per-resource):

access my custom resource:
  title: 'Access My Custom Resource API'
  description: 'Allows a role to call the my_custom_resource REST endpoint.'
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;

/**
 * {@inheritdoc}
 */
public function access(?AccountInterface $account = NULL, $return_as_object = FALSE) {
  $access = AccessResult::allowedIfHasPermission($account, 'access my custom resource');
  return $return_as_object ? $access : $access->isAllowed();
}

Now the endpoint requires both: successful authentication (from the previous lesson's config) and this specific permission, which an administrator grants only to the roles that should actually have it. Anonymous, newly-registered users are excluded by default unless you deliberately grant this permission to the Anonymous or Authenticated role.

When a static permission isn't precise enough

A permission check answers "can this role ever call this endpoint" — it can't express "can this specific user access this specific record." For that, combine the permission check with data-aware logic inside the same access() method or inside get() itself:

public function get($id) {
  $node = \Drupal::entityTypeManager()->getStorage('node')->load($id);
  if (!$node || !$node->access('view')) {
    throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException();
  }
  return new ResourceResponse(['title' => $node->getTitle()]);
}

This delegates to the node's own entity access system — the same rules that govern whether a user can view that node through the normal site UI apply here too, so a private or unpublished node stays private through the API exactly as it does everywhere else.

The most common real-world mistake: enabling cookie authentication on a resource and assuming that's sufficient protection because "you need to be logged in." Cookie authentication only confirms a session exists — it says nothing about which role that session belongs to. Without an explicit permission or entity-access check, every authenticated user (including a self-registered one with no special role) gets full access to the endpoint.

See how core does it

Core's own EntityResource plugin (the REST endpoint behind entity CRUD via the API) is a good reference for correct layering: it checks the requesting user's permission for the operation, then falls back to the entity's own access() method for the specific record — the exact two-layer pattern described above, just generalized to work for any entity type.

Quick check: you've enabled basic_auth on your resource and gated it with a custom permission, granted only to the "API Client" role. A user with the "Authenticated user" role (but not "API Client") sends valid Basic Auth credentials. What happens? (Answer: authentication succeeds — Drupal correctly identifies who they are — but the access check fails, since they don't hold the "API Client" permission. The request is rejected with a 403, not a 401; authentication and authorization failing look different for exactly this reason.)

Key takeaways

  • Authentication (proving identity) and authorization (permission to act) are separate layers — configuring supported_auth only handles the first one.
  • Gate a resource with a dedicated permission declared in *.permissions.yml, checked from the plugin's access() method.
  • For record-specific rules a static permission can't express, delegate to the entity's own access() method rather than reinventing access logic.
  • A failed authentication returns 401; a failed authorization (valid identity, insufficient permission) returns 403 — the distinction matters for debugging and for API clients.
  • "Requires login" is not the same as "properly secured" — an endpoint open to every authenticated user is still effectively public if no genuine role restriction exists.

Coming up next

You've now built, configured, and secured a REST resource end to end. The next lesson looks at the other side of the exchange: how to shape the JSON your endpoint actually returns — structuring response data cleanly, handling errors consistently, and avoiding the common trap of leaking internal entity structure straight into your API's public contract.