| name | drupal-security-patterns |
| description | OWASP prevention patterns, access control, input sanitization, and XSS protection for Drupal 10/11. Use when reviewing code for security issues, implementing access control, or hardening Drupal applications. |
Drupal Security Patterns
SQL Injection Prevention
$result = $connection->query("SELECT * FROM {node} WHERE title = '$title'");
$result = $connection->query(
"SELECT * FROM {node} WHERE title = :title",
[':title' => $title]
);
$nids = \Drupal::entityQuery('node')
->condition('title', $title)
->accessCheck(TRUE)
->execute();
XSS Protection
{{ node.title }}
use Drupal\Component\Utility\Html;
$safe = Html::escape($user_input);
use Drupal\Component\Utility\Xss;
$filtered = Xss::filter($user_input);
$filtered = Xss::filterAdmin($content);
Access Control
Route Access
my_module.admin:
path: '/admin/my-module'
defaults:
_controller: '\Drupal\my_module\Controller\AdminController::page'
requirements:
_permission: 'administer my_module'
my_module.content:
path: '/my-module/{node}'
defaults:
_controller: '\Drupal\my_module\Controller\ContentController::view'
requirements:
_entity_access: 'node.view'
Custom Access Checker
declare(strict_types=1);
namespace Drupal\my_module\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
final class MyAccessChecker implements AccessInterface {
public function access(AccountInterface $account): AccessResult {
return AccessResult::allowedIfHasPermission($account, 'access my_module')
->cachePerPermissions();
}
}
Stacked Route Access Checks
Routes can have multiple _*_access* requirements that ALL must pass (AND logic). Don't assume _permission is the only gate.
my_module.entity_edit:
path: '/entity/{entity}/edit'
requirements:
_entity_access: entity.update
_custom_archived_check: 'TRUE'
_custom_status_check: 'TRUE'
Debugging 403s when entity access passes:
- Read the route's
requirements in *.routing.yml
- Trace each
_*_access* checker service in *.services.yml
- Check each checker class individually -- any one returning DENIED blocks the route
During security review: Examine ALL route requirements, not just _permission. Custom access checkers may silently block access even when entity-level access is granted.
CSRF Protection
use Drupal\Core\Access\CsrfTokenGenerator;
$token = \Drupal::csrfToken()->get('my_module_action');
if (!\Drupal::csrfToken()->validate($token, 'my_module_action')) {
throw new AccessDeniedHttpException();
}
File Upload Validation
$validators = [
'file_validate_extensions' => ['pdf doc docx'],
'file_validate_size' => [25 * 1024 * 1024],
'file_validate_name_length' => [],
];
Security Checklist
Critical (Must Fix)
- No SQL injection (Entity API or parameterized queries).
- XSS protection (Twig auto-escape,
Html::escape).
- Access control on all routes and entities.
- Input sanitization via Form API validation.
- No hardcoded credentials.
- CSRF protection via Form API.
Important (Should Fix)
- Dependency injection used (no
\Drupal:: in classes).
- File upload validation (type, size, extension).
- Configuration exportable.
- No deprecated code.
- WCAG 2.1 AA compliance.
Architecture Red Flags
- Multiple responsibilities in one module.
- Service exposes internal implementation details.
- Direct class dependencies instead of interfaces.
- Hidden side effects or implicit contracts.
\Drupal:: static calls in service classes.