Preventing XSS in Output: Twig Auto-Escaping and the raw Filterfor Drupal 8 , 9 , 10 , and 11

Last updated :  

Picture a restaurant kitchen with a strict rule: nothing leaves the pass unless the head chef has personally tasted and approved it. That's Twig's default behavior in Drupal — every variable you print gets automatically checked and "cooked safe" before it ever reaches the browser. The danger only shows up when a developer tells the kitchen "skip the taste test on this one" — which is exactly what the |raw filter does, and exactly why it's the single most common source of real XSS bugs in Drupal themes and modules.

What you'll learn in this lesson

  • How Twig's automatic output escaping actually works, and why you rarely have to think about it
  • Exactly when Drupal considers a string "already safe" and skips escaping — and why that's a bigger attack surface than it sounds
  • What the |raw filter really does, and the narrow set of cases where using it is legitimate
  • How the Render API's own markup fields interact with Twig's escaping rules

Twig escapes everything printed, by default

{# This is safe automatically — Twig escapes $node.title before printing it #}

{{ node.title }}

Every {{ variable }} expression in a Drupal Twig template runs through Twig's escape filter automatically, using Drupal's HTML autoescape strategy. If node.title somehow contained <img src=x onerror=alert(1)>, Twig converts it to inert text before it ever reaches the DOM. You don't opt into this — it's the default for every template in the theme layer, with no configuration required.

"Safe markup" — the exception that needs your attention

Drupal's render pipeline has a concept of markup that's already been marked safe — typically because it went through $this->t(), was built entirely from hardcoded strings, or was explicitly wrapped in a Markup object. Twig detects this and does not re-escape it, on the assumption that whoever marked it safe already did the escaping work. This is correct and necessary — otherwise translated strings full of intentional <strong> tags would show up as literal angle brackets — but it means the safety of your page depends entirely on that earlier "mark it safe" step having been done correctly.

The |raw filter: explicitly turning escaping off

{# Dangerous if $user_bio came from an untrusted source #}
{{ user_bio|raw }}

|raw tells Twig "trust me, don't escape this" — full stop, no exceptions. If user_bio is a plain-text field a visitor typed into a profile form, this line is a textbook stored-XSS vulnerability: anything they typed, including <script> tags, renders and executes exactly as written, for every single visitor who views that profile.

When |raw is actually legitimate: a field explicitly stored through a restricted or full HTML text format, where Drupal's own filter system (not Twig) has already sanitized the HTML on save — for example, a body field rendered through {{ content.field_body }} via the normal field-formatter pipeline, which applies the configured text format's filters before the value ever reaches your template. The danger is using |raw on a value that skipped that filtering step entirely, like a plain textfield or a value you built yourself with string concatenation.

Render arrays: #markup vs. #plain_text

// Passes through the render pipeline's default HTML filtering.
$build['bio'] = ['#markup' => $user_bio];

// Guarantees the value is escaped as plain text, no HTML interpreted at all.
$build['bio'] = ['#plain_text' => $user_bio];

If a value should never contain any HTML at all — a username, a search term, a numeric ID — prefer #plain_text over #markup. It skips Drupal's HTML filter system entirely and guarantees the string is escaped as inert text, which is both safer and slightly faster than relying on the filter system to catch everything.

Quick check: a theme template does {{ content.field_comment_body }} without any filter — is that safe, or does it need |raw to display formatted comments correctly? (It's already safe and needs no |raw: content.field_comment_body is a rendered field output that has already passed through its configured text format's filters during the normal render pipeline — adding |raw here would be redundant at best, and dangerous if the field's text format ever gets loosened later.)

Key takeaways

  • Twig auto-escapes every {{ variable }} by default — you never need to add escaping yourself for ordinary output.
  • |raw disables escaping entirely and should only ever be used on values you're certain were already sanitized upstream (like rendered field output through a configured text format), never on raw form input.
  • Prefer #plain_text over #markup in render arrays whenever a value should never contain HTML at all.
  • "Marked safe" markup (from t(), hardcoded strings, or explicit Markup objects) skips Twig's re-escaping — the safety guarantee lives in whoever did the marking, so be conservative about what you mark safe.

Coming up next

Input sanitization and output escaping protect your rendered pages — but Drupal also needs to control who can even reach a given route or entity operation in the first place. Next, "Implementing Access Checks" covers AccessResult, the class Drupal uses to answer "can this user do this?" consistently across routes, entities, and custom logic.