Skip to content

Security & Performance

Security Best Practices

XSS Protection in Render Arrays

The Render API's security model:

Property XSS Protection When to Use
#plain_text Full protection -- all HTML escaped User input, untrusted data
#markup Filtered -- admin XSS filter applied Trusted admin content, module output
Markup::create() NONE -- you guarantee it's safe Already-sanitized HTML, trusted sources only
#value in html_tag Filtered if string, none if Markup Mix of trusted/untrusted

RULE 1: Never Use #markup with Raw User Input

VULNERABLE:

// User-submitted comment
$comment_body = $_POST['comment'];

return [
  '#markup' => $comment_body,
];
// Attacker submits: <script>alert('XSS')</script>
// Script executes on other users' browsers

SAFE:

use Drupal\Component\Utility\Html;

$comment_body = $_POST['comment'];

return [
  '#markup' => Html::escape($comment_body),
];

// BETTER: Use #plain_text
return [
  '#plain_text' => $comment_body,
];

Reference: OWASP XSS Prevention Cheat Sheet


RULE 2: Use t() with Placeholders, Not Concatenation

VULNERABLE:

$username = $user->getDisplayName();  // Could contain HTML

return [
  '#markup' => '<p>' . t('Welcome, ' . $username) . '</p>',
];
// t() doesn't escape the concatenated part

SAFE:

return [
  '#markup' => '<p>' . t('Welcome, @username', ['@username' => $username]) . '</p>',
];
// @placeholder escapes HTML
// !placeholder doesn't escape (for trusted HTML only)
// :placeholder escapes and wraps in <em> (for emphasized text)

RULE 3: Validate and Sanitize in Lazy Builders

Lazy builders run after cache retrieval -- validate all arguments:

public static function buildUserWidget($user_id) {
  // VALIDATE: Ensure $user_id is actually numeric
  if (!is_numeric($user_id) || $user_id < 1) {
    return ['#markup' => ''];
  }

  $user = User::load($user_id);

  // CHECK: Ensure user exists and current user can view it
  if (!$user || !$user->access('view')) {
    return ['#markup' => ''];
  }

  return [
    '#theme' => 'user_widget',
    '#user' => $user,
  ];
}

RULE 4: Use Access Checks, Not Manual Permission Checks

WRONG:

$current_user = \Drupal::currentUser();

if (in_array('administrator', $current_user->getRoles())) {
  // Show admin content
  $build['admin_section'] = ['#markup' => 'Admin stuff'];
}

RIGHT:

$access_result = $this->currentUser()->hasPermission('administer site configuration');

$build['admin_section'] = [
  '#access' => $access_result,
  '#markup' => 'Admin stuff',
  '#cache' => ['contexts' => ['user.permissions']],
];

Why: Using #access with proper cache contexts prevents caching the wrong access decision for different users.

Performance Best Practices

Cache Strategy Decision Tree

Does output vary by request?
+-- NO -> Cache with max-age = Cache::PERMANENT, keys only
+-- YES -> What does it vary by?
    +-- User identity -> Add context 'user'
    +-- User permissions -> Add context 'user.permissions'
    +-- URL -> Add context 'url.path' or 'url.query_args'
    +-- Language -> Add context 'languages:language_interface'
    +-- Time -> Use appropriate max-age (60 for 1 min, 3600 for 1 hour)

Does output depend on entities/config?
+-- YES -> Add cache tags from those objects
    Example: $node->getCacheTags(), $config->getCacheTags()

Is part of it expensive/uncacheable?
+-- YES -> Split into:
    +-- Cacheable part (static content)
    +-- Lazy builder for expensive/dynamic part

Performance Pattern: Lazy-Load Expensive Content

BEFORE (slow):

public function build() {
  // Expensive: API call, complex query, external service
  $expensive_data = $this->fetchDataFromAPI();

  return [
    '#markup' => $this->formatData($expensive_data),
    '#cache' => ['max-age' => 0],  // Can't cache (personalized)
  ];
}
// Result: Every page load waits for API call

AFTER (fast):

public function build() {
  return [
    'static_header' => [
      '#markup' => '<h2>Data Section</h2>',
      '#cache' => ['max-age' => Cache::PERMANENT],
    ],
    'dynamic_data' => [
      '#lazy_builder' => ['\Drupal\mymodule\Builder::fetchData', []],
      '#create_placeholder' => TRUE,
    ],
  ];
}

// In Builder::fetchData()
public static function fetchData() {
  $data = self::fetchDataFromAPI();  // Expensive call
  return [
    '#markup' => self::formatData($data),
    '#cache' => ['max-age' => 0],
  ];
}
// Result: Static header cached, API call only for dynamic part
// With Big Pipe: Page loads immediately, dynamic part streams in

Performance gain: 80%+ faster page loads for cached users.


Avoid Rendering in Loops

SLOW:

foreach ($items as $item) {
  $build[] = \Drupal::service('renderer')->render([
    '#markup' => $item->label(),
  ]);
}

FAST:

foreach ($items as $item) {
  $build[] = ['#markup' => $item->label()];
}
// Let parent render all children at once

Why: Each render() call has overhead. Batch rendering is more efficient.


Use #attached Efficiently

INEFFICIENT:

// Loading library for every item
foreach ($products as $product) {
  $build[$product->id()] = [
    '#theme' => 'product_teaser',
    '#product' => $product,
    '#attached' => ['library' => ['mymodule/product-behavior']],
  ];
}
// Result: Same library attached 20 times (though Drupal deduplicates)

EFFICIENT:

$build = [
  '#attached' => ['library' => ['mymodule/product-behavior']],
];

foreach ($products as $product) {
  $build[$product->id()] = [
    '#theme' => 'product_teaser',
    '#product' => $product,
  ];
}
// Result: Library attached once at parent level

Monitor Cache Hit Rates

// In a custom service or hook
public function logCachePerformance() {
  $cache = \Drupal::cache('render');

  // Check if specific render cache exists
  $cid = implode(':', ['mymodule', 'widget', $id]);
  $cached = $cache->get($cid);

  if ($cached) {
    \Drupal::logger('mymodule')->info('Cache hit for @cid', ['@cid' => $cid]);
  }
  else {
    \Drupal::logger('mymodule')->info('Cache miss for @cid', ['@cid' => $cid]);
  }
}

Analysis:

  • High cache miss rate -- Too many cache contexts, or keys not consistent
  • No cache hits -- Missing cache keys, or max-age = 0

Common Security Vulnerabilities

Vulnerability: Cached Access Decisions

VULNERABLE:

function mymodule_preprocess_node(&$variables) {
  $node = $variables['node'];

  // WRONG: Boolean access check
  if ($node->access('update')) {
    $variables['edit_link'] = Link::createFromRoute('Edit', 'entity.node.edit_form', ['node' => $node->id()]);
  }
}
// Result: If admin views node, edit link cached and shown to all users

SECURE:

function mymodule_preprocess_node(&$variables) {
  $node = $variables['node'];
  $access_result = $node->access('update', NULL, TRUE);

  $variables['edit_link'] = [
    '#access' => $access_result,
    '#type' => 'link',
    '#title' => t('Edit'),
    '#url' => Url::fromRoute('entity.node.edit_form', ['node' => $node->id()]),
  ];

  \Drupal::service('renderer')->addCacheableDependency($variables, $access_result);
}
// Result: Proper cache contexts ensure edit link only shown to users with permission

Vulnerability: Missing CSRF Protection

Render arrays don't provide CSRF protection -- use Form API for state-changing operations:

WRONG:

// Link that deletes content (GET request, no CSRF protection)
$build['delete_link'] = [
  '#type' => 'link',
  '#title' => t('Delete'),
  '#url' => Url::fromRoute('mymodule.delete', ['id' => $id]),
];

// In controller
public function delete($id) {
  Entity::load($id)->delete();  // VULNERABLE: Any GET request can trigger
  return new RedirectResponse('/');
}

RIGHT:

// Use confirm form (POST with CSRF token)
$build['delete_link'] = [
  '#type' => 'link',
  '#title' => t('Delete'),
  '#url' => Url::fromRoute('mymodule.delete_confirm', ['id' => $id]),
];

// In controller (confirm form)
public function deleteConfirm($id) {
  return $this->formBuilder()->getForm(
    '\Drupal\mymodule\Form\DeleteConfirmForm',
    $id
  );
}

See Also