Notes · Dissecting Real Systems
growing
Strings Do Not Remember Where They Came From
Trusted Types does not make XSS impossible — the spec says so itself. It makes unreviewed assignment impossible, which is a smaller claim and a far more useful one.
In essence, DOM-based XSS exists because there lacks a mechanism in the JavaScript language or the Web APIs to distinguish trusted from untrusted data.
— Pei Wang, Bjarki Ágúst Guðmundsson, Krzysztof Kotowicz, Adopting Trusted Types in Production Web Frameworks to Prevent DOM-Based Cross-Site Scripting (IEEE EuroS&PW, 2021)
Cite this
Mangalapilly, Y. J. (2026, August). Strings Do Not Remember Where They Came From. Saṃhitā Notes. https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/ @online{mangalapilly2026strings,
author = {Yesudeep Jose Mangalapilly},
title = {Strings Do Not Remember Where They Came From},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {August},
url = {https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Strings Do Not Remember Where They Came From.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Strings Do Not Remember Where They Came From
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/
Y2 - 2026-08-12
ER - What a type system can enforce when it cannot know what is safe. Trusted Types stops DOM XSS by requiring typed objects at DOM sinks instead of inspecting arbitrary strings. By the end you'll know why provenance rather than content is the missing property, what the three types actually are, why the default policy is both the migration path and the way deployments quietly fail, what Trusted Types explicitly does not protect, and what it really cost the team that shipped it.
The last piece left a gap open.
A sanitizer inspects content and decides whether it is dangerous. It has to be right about every input, forever, against a grammar it does not own. Moving that job into the browser removes the disagreement between two parsers, which is a real improvement — but it still leaves you needing the inspection to be complete, and completeness is exactly what nobody can demonstrate.
So try the other direction. Instead of asking whether this string is dangerous, ask where it came from.
The property strings do not have
Here is the observation the whole design rests on, from the Trusted Types specification's introduction:
Applications commonly call those injection sinks with attacker-controlled values without authors realizing it, since it's not clear if the input was attacker-controlled when invoking the injection sink.
And more precisely, in §2.1:
…(usually strings) do not carry the information about their provenance. For example, while the application might intentionally call
eval()with dynamically created inputs (e.g. for code obfuscation purposes), callingeval()on strings supplied by the attacker is definitely a security vulnerability — but it's not easy to distinguish one from the other.
Provenance — where a value came from and what happened to it on the way. A string carries none: two identical strings are indistinguishable regardless of whether one was typed by a developer and the other arrived in a query parameter.
Two strings with identical bytes are the same string. One came from a template literal in your source; the other came from location.hash. At the moment of assignment, element.innerHTML = value, nothing in the language can tell them apart — and the sink cannot ask, because by then the history is gone.
The specification is candid about how hard this is to check statically:
Due to the dynamic nature of JavaScript it's also difficult to ascertain that such pattern is not present in a given program… As an example, if aString contains untrusted data,
foo[bar] = aStringis a statement that potentially can trigger a vulnerability, depending on a value of foo and bar.
That example deserves a moment. foo[bar] = aString is not obviously a DOM write. Whether it is one depends on runtime values. No linter is going to resolve that in general, which is why "just audit your innerHTML calls" is advice that cannot be completed.
The measurement supports the specification's pessimism. 25 Million Flows Later (Lekies, Stock and Johns, CCS 2013) instrumented a browser to taint- track values from attacker-controllable sources into DOM sinks across the Alexa top 5,000, and generated working exploits rather than reporting suspicions. It found 6,167 validated DOM-based XSS vulnerabilities across 480 domains — 9.6% of the sites studied, in a set consisting entirely of the most-visited and best-resourced sites on the web.
The relevant part is not the percentage. It is that finding those flaws required a taint-tracking JavaScript engine and 25 million observed flows, because the defect is a property of a path through a program rather than of any line in it. That is the same reason auditing cannot complete, stated as an experiment.
What the mechanism actually is
Trusted Types adds three types the DOM will accept where it previously accepted strings: TrustedHTML, TrustedScript, and TrustedScriptURL. Three, not four — the policy and factory objects are machinery, not trusted values.
You cannot construct them. You obtain them from a policy:
const policy = trustedTypes.createPolicy('sanitize-comment', {
createHTML: (input) => DOMPurify.sanitize(input),
});
element.innerHTML = policy.createHTML(untrusted);The unforgeability is explicit in §2.2, and it is what makes the whole thing work rather than merely encourage:
All Trusted Types wrap over an immutable string, specified when the objects are created. These objects are unforgeable in a sense that there is no JavaScript-exposed way to replace the inner string value of a given object - it's stored in an internal slot with no setter exposed.
Under enforcement, assigning a plain string to a sink throws a TypeError. Not a warning, not a sanitized fallback — the assignment fails.
Important
Trusted Types is enforced by CSP but is not itself CSP. Two directives turn it on: require-trusted-types-for 'script' makes the sinks demand typed values, and trusted-types <names> controls which policy names may be created at all. The second is the one people forget, and it is the one that stops an attacker who achieves script execution from simply minting their own policy.
The trusted-types directive is a genuine allowlist over policy names. An empty value means no policies may be created, which the spec spells out as "no DOM XSS injection sinks can be used at all." * permits any unique name. allow-duplicates permits reusing a name — with one exception worth knowing, since it is the sort of thing people assume: a second policy named default throws unconditionally, even with allow-duplicates.
The sink inventory
The question "where can a string become code?" has an answer, and the answer being enumerable is half the value on offer.
The specification's introduction puts the scale plainly: there are "over 60 different injection sinks." MDN enumerates thirty-one direct ones, grouped by the type each demands. A sample, to show the shape of the problem:
| type | representative sinks |
|---|---|
TrustedHTML | innerHTML, outerHTML, document.write(), insertAdjacentHTML, iframe.srcdoc, DOMParser.parseFromString(), Range.createContextualFragment(), setHTMLUnsafe() |
TrustedScript | eval(), Function(), setTimeout() / setInterval() with a string, script.text / .textContent / .innerText |
TrustedScriptURL | script.src, Worker(), SharedWorker(), importScripts(), serviceWorker.register() |
Two subtleties keep this from being a tidy list.
First, Element.setAttribute is element- and attribute-dependent — the type it requires is computed rather than fixed, which is why trustedTypes.getAttributeType() exists at all. Second, there are indirect sinks the enforcement does not cover at the point you would expect. Appending a text node into a <script> element is not a listed sink, so the check happens later, when the element becomes executable. MDN states the consequence directly: any operation letting the text source of a <script> be modified without explicitly setting a TrustedScript makes it untrusted.
Warning
"Enumerable" is not "small," and it is not "obvious." Very few developers could list ten sinks from memory, and the ones people forget — srcdoc, createContextualFragment, the worker constructors — are exactly the ones a manual audit misses. That gap between the real inventory and the remembered one is the argument for machine enforcement.
The default policy
Here is where deployments go wrong, and the specification predicted it.
A policy named exactly default is special. When a plain string reaches a sink and no explicit policy was used, the browser calls this one implicitly rather than throwing. It receives three arguments — not one, as is often assumed:
trustedTypes.createPolicy('default', {
createScriptURL: (value, type, sink) => {
console.log('Please refactor.');
return value;
},
});value is the string, type is which trusted type was expected (TrustedScriptURL), and sink names the exact site — for example HTMLScriptElement src. That third argument is what makes the default policy useful as an instrument: it tells you where in a codebase the unconverted assignments actually live.
It is also the failure mode. The specification's warning is unusually direct, and worth quoting in full:
Needless to say, this policy should necessarily be defined with very strict rules not to bypass the security restrictions in unknown parts of the application. In an extreme case, a lax, no-op default policy defeats all the benefits of using Trusted Types to protect access to injection sinks.
A default policy that returns its input unchanged is a Trusted Types deployment that reports success and protects nothing.
The Google engineers who migrated Angular say the same thing about the temptation, from the other end — the one where the goal has quietly become making the errors stop:
Mechanically eliminating all incompatibilities with Trusted Types is not the purpose, as it can be done as easily as defining a trivial default policy… That, however, does not help improve the security of the project in any regard.
Imagine a building where every door needs a badge. Deployment is hard, so someone props a badge reader by the entrance that beeps and opens for anyone. Every door is now technically badge-controlled. The logs are full. Nobody has been kept out of anything.
The honest use is transitional and instrumented: define the default policy, log what flows through it, and let the sink argument enumerate your remaining work. Then delete it.
Report-only, and what a violation tells you
Unlike HSTS, and like CSP proper, this mechanism has a dry run. Ship the directives under Content-Security-Policy-Report-Only and violations are reported rather than thrown.
The reports are specific enough to act on. A sink violation names the sink and the first forty characters of the value:
{"csp-report": {
"document-uri": "https://my.url.example",
"violated-directive": "require-trusted-types-for",
"disposition": "report",
"blocked-uri": "trusted-types-sink",
"line-number": 39,
"source-file": "https://my.url.example/script.js",
"script-sample": "Element innerHTML <img src=x"
}}
The forty-character cap is deliberate, and it is a privacy decision rather than a technical limit — the payload passing through a sink may contain user data, so the report carries enough to locate the bug and not enough to leak the document. There is a second violation kind too: attempting to create a policy whose name is not allowed reports blocked-uri: trusted-types-policy.
Note
The report-only rollout has the same shape the closing piece argues for generally: deny by default, discover the exceptions by observation rather than by reasoning, then enforce. Trusted Types is unusually well suited to it, because the report tells you the sink and the file and the line. The exceptions enumerate themselves.
What it does not do
The specification's non-goals section is refreshingly blunt, and skipping it is how people end up believing they have bought more than they have.
- Server-side XSS is out of scope. Reflections into server-generated markup are explicitly not addressed; the spec points you at templating systems and
script-srcinstead. Trusted Types is a DOM XSS mechanism. - It does not stop malicious first-party code. From §5: the intent "is to prevent developer mistakes that could result in security bugs, and not to defend against first-party malicious code actively trying to bypass policy restrictions."
- Script gadgets survive. The spec says it "should not be treated as a mechanism for guarding all DOM tree creation in a document."
- Insecure policies stay insecure. "In this design, insecure policies can still expose injection sinks to untrusted data." If your policy wraps a buggy sanitizer, you have a typed path to the same vulnerability.
That last one is the crux, and it returns us to the opening. Trusted Types does not know what is safe. It cannot. What it knows is that a value arrived through a policy you named, which means the security review has a finite object to examine.
Note: Trusted in this context signifies the fact that the application author is confident that a given value can be safely used with an injection sink - she trusts it does not introduce a vulnerability. That does not imply that the value is indeed safe.
The explainer states the resulting benefit exactly, and it is a claim about reviewers rather than about attackers:
These types do not obliterate DOM XSS in themselves, as authors may still create an instance of a type with an untrusted value. Instead, it simplifies the security analysis of the application - security reviewers don't need to deeply understand and review each and every usage of a given sink, but can instead focus their efforts on the code that generates the typed objects.
Where this came from
Trusted Types did not arrive from a standards committee reasoning about types in the abstract. It is the browser-facing descendant of a system Google built and ran internally, and the paper describing that system says so directly:
Part of our work has evolved into an emerging web security standard called Trusted Types which introduces run-time DOM API checks into browsers…
and, in its appendix, more plainly still — Trusted Types is "a new web security standard that originated from API hardening at Google."
That predecessor is worth knowing about, because it makes the same move one step earlier. If It's Not Secure, It Should Not Compile describes banning the dangerous DOM sinks at compile time and supplying typed replacements, which reduces "the task of demonstrating the absence of XSS vulnerabilities to a type checking problem." Six safe types, three components — safe types, safe builders that are the only way to construct them, and safe sinks that accept nothing else. If that sounds like the mechanism this article has been describing, it is: Trusted Types is that design moved into the browser, where it can also protect code you did not compile.
Note
The compile-time and runtime versions are complements rather than competitors, and the reason is worth stating. A compiler can only check code it compiles — useless against a third-party script loaded at runtime. A browser check applies to everything in the page but cannot run until the assignment happens. Google runs both, and the open-source library is explicitly "customized to cooperate with Trusted Types."
What it cost, and what nobody has measured
Two things are worth stating plainly, because the popular account of Trusted Types is more confident than the evidence.
First, the cost is real but bounded. The team that migrated Angular reports "a small team of four security engineers and an intern roughly six months to complete the migration, without working full-time on the project" — of which "only around six weeks were spent on the concrete design and implementation." Fourteen pull requests, about 2,600 lines added and modified. They note that comparable integrations with Lit and React were "noticeably easier," measured in days and weeks.
Second, common accounts repeat a headline effectiveness claim that lacks a primary source: that Google deployed Trusted Types across a hundred-plus services and eliminated DOM XSS outright. What Google's own peer-reviewed paper says, in its threats-to-validity section, is this:
Although we have successfully adopted Trusted Types in Angular, it remains to be seen how effective the new security mechanism is against XSS threats. Previous research has shown that static enforcement of safe coding practices can effectively prevent XSS vulnerabilities, but we do not yet have quantitative measurement regarding the effectiveness of Trusted Types at a large scale.
Warning
When a mechanism's own authors decline to claim an effectiveness number, and the number circulating anyway traces only to secondary coverage, the honest position is to argue from mechanism rather than from measurement. The mechanism argument here is strong. The measurement does not exist.
Notice which research that passage defers to. "Static enforcement of safe coding practices" cites the compile-time predecessor — and that paper does report numbers, for one product across three years:
| period | DOM-based XSS reports |
|---|---|
| year before adoption | 10 |
| year during adoption | 2 |
| year after adoption | 1 |
So the evidence is lopsided in an interesting direction. The approach that catches errors before the code ships has a measured result; the approach that catches them in the browser, which is newer and far more widely deployed, does not yet. Two caveats keep that from being a verdict: it is one product with a few tens of frontend engineers who spent about a year refactoring, measured by externally-reported bug bounty submissions rather than ground truth — and both papers share a first author, so this is a research group citing itself rather than independent corroboration.
What the paper does offer is a concrete anecdote worth more than a statistic: an Angular bug where i18n handling bypassed sanitization on an <iframe srcdoc>. Applications with Trusted Types enforced "would therefore be immune to the vulnerability, even though it occurs deep within the internals of the Angular framework." The value showed up where it was designed to — a mistake in code nobody was auditing became a violation instead of an exploit.
Deploying it
Support is no longer the obstacle it was. Trusted Types shipped in Chrome 83 in 2020 and reached the last engine with Firefox 148 in February 2026, making it newly Baseline. Safari 26 supports it; that is the one most people have out of date, because Safari does not ship the safe setHTML() from the previous piece — two different features with two different support stories.
For older browsers, the compatibility story is unusually elegant. Because every trusted type stringifies to its inner value, this suffices:
if (typeof trustedTypes === 'undefined')
trustedTypes = { createPolicy: (n, rules) => rules };Your policy still runs — the sanitization still happens — you simply get a plain string back and no enforcement. The security work degrades gracefully into being merely correct.
A sensible order:
- Enable report-only with
require-trusted-types-for 'script'and collect violations. The reports enumerate your sinks with file and line. - Add a logging default policy to capture what report-only misses, using the
sinkargument to build the work list. Do not sanitize in it yet. - Convert the real call sites to named policies, narrowest first. A policy per purpose beats one general-purpose policy, because the review target is the policy.
- Constrain the names with
trusted-types, so an attacker with script execution cannot mint a permissive policy. - Delete the default policy, then enforce.
The library, and what it reveals
You do not have to write the builders yourself. Google open-sourced the safe-API layer as safevalues, and reading it is instructive beyond its use as a dependency.
The usage shape is two halves. Build a value:
import {sanitizeHtml, trustedResourceUrl} from 'safevalues';
const html = sanitizeHtml('<article>my post <script>alert(0)</script></article>');
const url = trustedResourceUrl`/static/${env}/js/main.js`;then assign it through a wrapper rather than to the raw sink:
import {setElementInnerHtml, setScriptSrc} from 'safevalues/dom';
setElementInnerHtml(el, html);
setScriptSrc(scriptEl, url);The template-literal builders are the neat part. Interpolations into trustedResourceUrl are passed through encodeURIComponent, so an injected value can populate a path segment or a query parameter but can never relocate the origin — the developer-authored literal fixes the shape and the untrusted part can only fill a hole.
Important
Read the library's own Trusted Types policy before you assume where the safety lives. It creates a policy named google#safe whose createHTML, createScript and createScriptURL are all the identity function. The policy inspects nothing. Every guarantee comes from the builder that ran earlier, and the policy is purely a capability token — the thing that makes the value unforgeable. If you adopt the library, that policy name has to appear in your trusted-types directive, which the documentation does not tell you.
Two design choices are worth stealing whether or not you take the dependency. The included sanitizer can be restricted but never loosened — its own documentation says there is "no builder available that lets you create arbitrarily looser policies than the default policy" — which is the opposite of the configure-yourself-into-a-hole failure mode of a general-purpose sanitizer. And the escape hatch for values whose provenance you cannot yet prove requires a written justification argument, so bypassing the system leaves a reviewable artifact rather than a silent cast.
Warning
Check the state of this tooling before depending on it. The libraries carry the "not an officially supported Google product" disclaimer; SafeUrl and SafeStyle existed in the paper and have since been removed in favor of sanitizing URLs at the sink; and the compile-time checker tsec is being superseded by an ESLint plugin that still describes itself as not ready for production. The ideas are stable. The packaging is not.
The migration finished ahead of schedule. Violations went to zero in a week, enforcement went on, and the ticket closed.
What shipped was a default policy returning its argument. It had been added on day three to unblock a third-party widget, with a comment saying "temporary." Every sink in the application was now routed through a function that inspected nothing, and the dashboard showed full coverage — because coverage was exactly what it had.
The audit that found it two years later did not find it by reading the policy. It found it by asking why a security control with a documented rollout cost of several engineer-months had taken eight days.
Lessons
- The missing property is provenance, not safety. Identical strings are indistinguishable at the sink; the type preserves a fact about the journey rather than a judgment about the content.
- The types are unforgeable, and that is the whole enforcement. The inner string lives in an internal slot with no setter, so a policy is the only source.
- Trusted Types is enforced by CSP but is not CSP.
require-trusted-types-forturns the sinks on;trusted-typesconstrains which policies may exist, and omitting the second leaves an attacker free to mint one. - The default policy is the migration path and the standard way to fail. A no-op default reports success and protects nothing — the spec says so outright.
- Trusted ≠ safe. The mechanism guarantees a named policy processed the value, not that the value is harmless. If the policy is wrong, you have a typed path to the same bug.
- The real win is review surface. An unbounded question about every DOM write becomes a bounded question about a few policies.
- Argue from mechanism, not from the circulating number. Google's own paper declines to claim a large-scale effectiveness measurement.
Practice
References
- W3C Web Application Security WG. “Trusted Types.” W3C Working Draft, 2026. — the normative spec: provenance argument, unforgeability, the default policy warning, non-goals and threat model
- Wang, Guðmundsson, Kotowicz. “Adopting Trusted Types in Production Web Frameworks to Prevent DOM-Based Cross-Site Scripting: A Case Study.” IEEE EuroS&PW, 2021. — the Angular migration, its measured engineering cost, and the explicit statement that large-scale effectiveness is unmeasured
- W3C. “Trusted Types explainer.” GitHub. — the review-surface argument, and the acknowledgment that the types do not obliterate DOM XSS by themselves
- MDN. “Trusted Types API.” MDN. — the enumerated sink inventory, indirect sinks, the tinyfill, and browser support
- MDN. “require-trusted-types-for.” MDN. — what enforcement does to a plain-string assignment
- Kotowicz. “Prevent DOM-based cross-site scripting vulnerabilities with Trusted Types.” web.dev. — the deployment sequence, violation report shape, and the caveat that a buggy policy still yields a vulnerability
- Wang, Bangert, Kern. “If It's Not Secure, It Should Not Compile: Preventing DOM-Based XSS in Large-Scale Web Development with API Hardening.” ICSE, 2021. — the compile-time ancestor Trusted Types originated from, its six safe types, and the measured 10-to-1 result
- Google. “safevalues.” GitHub. — the open-source safe-API layer: template-literal builders, the DOM sink wrappers, and the identity-function policy
- Christoph Kern. “Securing the Tangled Web.” Communications of the ACM 57(9), 2014. — the inherently-safe-API lineage the design descends from
How to cite
Mangalapilly, Y. J. (2026, August). Strings Do Not Remember Where They Came From. Saṃhitā Notes. https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/ @online{mangalapilly2026strings,
author = {Yesudeep Jose Mangalapilly},
title = {Strings Do Not Remember Where They Came From},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {August},
url = {https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Strings Do Not Remember Where They Came From.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Strings Do Not Remember Where They Came From
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/strings-do-not-remember-where-they-came-from/
Y2 - 2026-08-12
ER - Webmentions
Annotations
Thank you — your note is held for review and will appear once approved.
Thank you — your note is published.
Please sign in below to leave a note.
