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.
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_authon 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_authonly handles the first one. - Gate a resource with a dedicated permission declared in
*.permissions.yml, checked from the plugin'saccess()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.