Module · Level 1

Security

HTML Next defines a secure execution model, not a collection of optional precautions. Expressions cannot execute JavaScript; executable attributes and raw DOM sinks are rejected; text, markup, URLs, properties, imports, and controllers each pass through a sink-specific policy. A conforming implementation must fail closed when it cannot enforce one.

Level 1 · normative

What that looks like in practice: expressions never run as code, dangerous sinks are unreachable by an ordinary binding, and dynamic content enters the DOM only through an operation that knows what kind of content it is receiving.

<!-- Blocked: no expression is ever eval'd; a bound javascript: URL is dropped -->
<a :href="user.website"></a>       <!-- javascript:… as the value → attribute removed -->
<button onclick="…">                 <!-- inline handler → non-conforming, rejected -->

<!-- Safe sinks are explicit -->
<h2 $value="post.title">             <!-- textContent: markup stays text -->
<article $html="post.body">          <!-- Sanitizer API or a conforming equivalent -->

Secure by construction

The source language is deliberately less powerful than JavaScript. The compiler does not accept arbitrary executable markup and hope an application configures it safely later; it classifies every dynamic value by destination and emits only the operation permitted for that destination.

Input or destinationRequired handling
HTML Next expressionParse with the closed expression grammar and evaluate against declared scope. No global lookup, prototype traversal, eval(), or generated function.
Text via $valueWrite through textContent or the target framework's text node operation, so markup is never interpreted.
Markup via $htmlUse the HTML Sanitizer API with the HTML Next safe configuration, or a conforming equivalent, before nodes become live.
URL-valued attributeParse and validate for that attribute's URL policy. Reject executable schemes and fail closed on an unparseable value.
DOM property bindingResolve through the generated allowlist for the element interface. Raw-code and raw-markup sinks are not bindable.
Literal executable syntaxReject inline on* handlers and target-framework directives rather than copying them through.
Controller or importA definition may name the controller it requests, but that link cannot execute it. Only a matching application-owned import-map entry may authorize the controller; CSP and CORS still apply, and declared integrity is enforced.

Conformance requirements

  1. The compiler and runtime must not evaluate source through eval(), new Function(), or equivalent. Expressions are parsed by HTML Next's restricted expression language; names resolve only from the declared scope and built-in pure functions.
  2. An implementation must reject inline event-handler attributes, framework directive syntax, bindings to raw properties such as innerHTML, outerHTML, and srcdoc, and any destination for which it has no defined sink policy.
  3. $value must produce text. $html must sanitize with the standard safe configuration before insertion (see Templating). URL, style, and future trusted-content values must use their own contextual parser and policy; generic escaping is not a substitute.
  4. Every generated target must preserve these checks. A converter may enforce a rule at compile time or emit a target-native runtime guard, but it must not weaken a rule because React, Vue, Svelte, or the DOM exposes a more permissive sink.
  5. Property resolution uses a generated static manifest keyed by ASCII-lowercase name, never runtime prototype enumeration, and must reject two properties that collapse to the same key rather than pick a winner.
  6. A component definition, including one reached through a transitive <link rel="component">, may name a requested module with <link rel="controller" href>, but that link must not authorize script execution or acquire script-fetch semantics.
  7. The importing application must independently map the conventional bare specifier html-next-controller/tag to an approved module URL, or load the module itself. The runtime must resolve that specifier without fetching, compare it with the definition's requested URL, and import the specifier only after an exact match. An absent or mismatched mapping must leave the declarative baseline in place and execute nothing.
  8. An implementation must honor import-map integrity metadata and integrity-bearing module entry points. Tooling should generate integrity metadata for cross-origin or independently hosted controllers and may generate it for all production assets. Integrity is hardening against changed bytes, not controller authorization; its absence does not turn an explicitly mapped same-origin module into an implicit import.
  9. If an implementation cannot apply a required sanitizer, URL policy, property allowlist, declared integrity check, or application controller mapping, it must reject the source or value. Silent pass-through is non-conforming.
ES modules are not a sandbox

A controller is ordinary JavaScript. Once its module is authorized and evaluated, it can use window, document, storage, network APIs, and any other authority available to page script. The host object is a small, portable programming interface; it is not a capability membrane. ESM supplies a standard dependency graph, module scope, strict mode, CORS fetching, caching, and one-time evaluation. None of those properties confines what evaluated code may do.

Therefore an approved controller and its complete transitive module graph are trusted application code. CSP limits where code may come from, and integrity pins which bytes may run, but neither turns approved code into untrusted code. If code needs an actual authority boundary, it must run in a separate environment such as a Worker or sandboxed iframe and communicate through messages; that stronger isolation trades away direct page-DOM access.

Why this is different from HTML Imports

The difference is not that ESM is magically safer. It is that HTML Next separates permission to consume data from permission to execute code.

HTML ImportsHTML Next component import
Imported unitAn HTML Document containing markup, styles, dependencies, and scripts.A component definition processed as data.
Transitive scriptScripts in an imported document were enabled and could execute as part of the import graph.Scripts and inline handlers are rejected. <link rel="component"> can never authorize code.
Controller selectionThe imported resource could carry executable dependencies whose scripts ran as part of importing it.A definition names what it requests; the application separately approves the exact tag-to-module mapping before anything runs.
Execution modelA separate HTML-import document, dependency, parsing, and script-ordering model.The existing JavaScript module loader, CSP, CORS, import maps, and integrity model.
Authority after executionPage-level JavaScript authority.Also page-level JavaScript authority. ESM does not improve this part.

The old HTML Imports proposal was explicit that scripting was enabled in imported documents; it has since been retired by W3C. Its difficulty was broader than “too many script tags”: an HTML-document dependency graph also needed rules for parsing, parser blocking, transitive script order, style order, deduplication, document.currentScript, and custom-element upgrades. It created a second loading and execution model alongside JavaScript modules, while a content import still carried page-authority code.

HTML Next's defensible boundary is narrower: importing a component definition cannot cause code to run, even when that definition was discovered transitively. A fetched definition may name the controller it requests, preserving a self-describing component, but the application must independently map html-next-controller/tag to that same resolved URL. The runtime uses import.meta.resolve() to compare them without fetching and imports the conventional specifier only after they match.

The line we must not cross

If the runtime automatically imported whatever controller URL a fetched component named, the security distinction would collapse. The code would use ESM plumbing, but the downstream component would still have gained script-execution authority merely by being imported. Naming a dependency is useful; treating that name as permission to run it is not acceptable here.

What approval looks like

No second policy format is needed. The definition records its relative request; the page uses the standard import map to approve the conventional name. The optional standard integrity table can pin executable bytes:

<!-- inside /components/chart.html: discovery only -->
<link rel="controller" href="./chart.js">

<!-- in the application document: execution approval -->
<script type="importmap">
{
  "imports": {
    "html-next-controller/x-chart": "/components/chart.js"
  },
  "integrity": { "/components/chart.js": "sha384-…" }
}
</script>
  1. The runtime resolves ./chart.js against the definition URL.
  2. It asks import.meta.resolve("html-next-controller/x-chart") what the application mapped, without fetching or executing that module.
  3. It compares the two serialized absolute URLs. Only an exact match reaches import("html-next-controller/x-chart"); the browser then applies the normal module loader, CSP, CORS, and any import-map integrity metadata.
  4. A missing entry or different URL executes nothing. A declared hash mismatch fails the module fetch. In either case the component retains its declarative rendering and the runtime reports a diagnostic.
Approval should feel like installing a dependency

Authors should not hand-calculate hashes or maintain generated mappings unaided. In development, the tool reports an unapproved controller with the exact import-map line needed to approve it. A project build can emit mappings for explicitly selected local source; a package install can record the package's controller mappings; a new remote origin requires explicit acceptance. Production never prompts, broadens a mapping, or converts every discovered request into permission: it only consumes the application-owned map.

How the component arrivedFriendly application behavior
First-party project sourceThe dev server and production build generate exact mappings from the project's explicitly selected source graph. The author sees ordinary dependency output, not a permission prompt.
Installed packageThe install/build tool lists newly introduced controllers and records exact mappings in generated output. Review happens with the dependency change, not when an end user opens the page.
Remote component URLThe tool requires explicit approval of the controller origin and URL, then generates integrity metadata when the resource is independently hosted.
No-build pageThe author adds one ordinary import-map entry per lazy controller. Loading the module eagerly with <script type="module"> remains the simpler standard alternative.

An exact entry is the safe default. An application may deliberately use an import-map prefix for a controlled first-party controller namespace, trading per-controller review for convenience. That is still application authorization, but it is a broader grant and tooling must describe it as such rather than presenting it as equivalent to an exact entry.

A proportionate boundary, not a zero-trust runtime

This check has one job: prevent a transitive data dependency from silently promoting a newly discovered URL into page-authority JavaScript. It does not ask the browser to distrust code the application deliberately installed. There is no end-user permission prompt, no per-instance decision, no requirement that ordinary controllers run in a sandbox, and no mandatory hand-authored hash for a same-origin module produced by the application's own build.

The application developer approves a controller once, at the same point they add or update a dependency. Tooling writes the generated mapping and, where useful, its integrity metadata. At runtime the check is mechanical and invisible. Purely declarative components need no controller approval at all; only the transition from inert component data to executable code crosses this boundary.

This protects against a component update or transitive definition unexpectedly adding or redirecting executable behavior. It does not protect against a malicious module the application knowingly approved, a compromised application document that can rewrite both policy and code, or bugs in trusted controller code. Claiming otherwise would be security theater.

Authorization is required; integrity is risk-based

MechanismWhat it protectsWhat it does not do
Import-map mappingPrevents a component definition from choosing code the application did not approve for that tag.Does not make the approved code safe.
CSP and CORSConstrain permitted script origins and cross-origin sharing under the platform's existing rules.An origin allowlist is not an exact module allowlist; approved code still has page authority.
Import-map integrity / SRIDetects changed bytes, especially useful for a CDN or independently hosted dependency.Does not protect against code that was malicious when approved, and adds little when an attacker can also rewrite the application document and its hashes.
Worker or sandboxed iframeCreates an actual authority boundary for code that is not trusted with the page.Does not provide direct DOM access; integration must use messages.

A normal same-origin application may reasonably rely on its explicit mapping, HTTPS deployment, CSP, and content-hashed build assets. Cross-origin controllers should carry generated integrity metadata and must satisfy CORS. A high-assurance deployment—not the general default—can pin the complete module graph and adopt Integrity-Policy as browser support matures. None of those choices changes the mandatory rule: definition discovery alone never authorizes execution.