strict Content Security Policy without eval()
The default Alpine.js build evaluates expressions in x-data and x-on through new Function(), which gets blocked immediately under a strict Content Security Policy without unsafe-eval. The dedicated Alpine.js CSP build replaces that evaluation with a safe expression parser, making reactive components possible even in environments with the strictest security policy.
Table of Contents
- 1. Why the default Alpine.js build needs unsafe-eval
- 2. Content Security Policy: script-src and the role of unsafe-eval
- 3. Including the Alpine.js CSP build
- 4. Alpine.data(): moving expressions from HTML into JavaScript
- 5. What no longer works in the CSP build
- 6. Combining a nonce strategy for inline scripts
- 7. Migrating existing components step by step
- 8. Common mistakes when switching to the CSP build
- 9. Default build vs. CSP build compared
- 10. Summary
- 11. FAQ
1. Why the default Alpine.js build needs unsafe-eval
Alpine.js evaluates expressions like x-data="{ open: false }" or x-on:click="open = !open" at runtime by passing the string as a function body to new Function(). That is exactly why developers can write arbitrary JavaScript expressions directly inside an HTML attribute without compiling anything beforehand. But this very ability to turn strings into code falls under unsafe-eval in a Content Security Policy, because the same technique can be abused for cross-site scripting.
In environments with a strict security policy, such as banks, government sites, browser extensions or Electron apps, unsafe-eval is usually forbidden entirely because it would undermine one of the most important protections of the CSP. This is exactly the case the dedicated Alpine.js CSP build was built for, offering the same feature set without new Function() and without eval().
The rest of this article shows how the Alpine.js CSP build works internally, which syntax restrictions it brings, how Alpine.data() becomes the central solution, and how migrating an existing project to a strict Content Security Policy plays out in practice.
2. Content Security Policy: script-src and the role of unsafe-eval
A Content Security Policy is set through the Content-Security-Policy HTTP header or a meta tag and restricts which script sources a browser is allowed to execute at all. The directive script-src 'self' only allows scripts from the same domain, but by default it also blocks eval(), new Function() and similar dynamic code evaluation mechanisms, unless 'unsafe-eval' is explicitly added.
unsafe-eval carries the word unsafe for good reason: if an attacker finds a way to inject controllable string input into an eval-like function, for instance through an improperly escaped templating variable, the result can be arbitrary code execution in the context of the page. That is why many security audits and compliance requirements, for instance in the PCI-DSS space, forbid unsafe-eval outright. The Alpine.js CSP build is exactly the answer to that requirement, without having to give up Alpine as a framework.
3. Including the Alpine.js CSP build
Instead of the default alpinejs package, strict environments include the @alpinejs/csp package. It exports the same global Alpine API, but internally uses a hand-written expression parser instead of new Function(). That means most existing templates keep working unchanged, only certain advanced JavaScript constructs in inline expressions are no longer allowed.
// package.json — swap the default build for the CSP-compatible one
{
"dependencies": {
"@alpinejs/csp": "^3.14.0"
}
}
// resources/js/app.js
import Alpine from '@alpinejs/csp'
window.Alpine = Alpine
Alpine.start()
// CSP header that would block the default Alpine build entirely
// Content-Security-Policy: script-src 'self'; object-src 'none';
// No 'unsafe-eval' needed with @alpinejs/csp
It matters that the Alpine.js CSP build requires no compile step or build tooling. It is a drop-in replacement that runs at runtime in the browser, but internally relies on a tokenizer and parser for the limited Alpine expression grammar instead of the generic JavaScript evaluation of new Function(). That explains why some expressions that work in the default build produce a parser error in the CSP build.
4. Alpine.data(): moving expressions from HTML into JavaScript
The central strategy in the Alpine.js CSP build is to stop writing complex logic as an inline string in an HTML attribute and instead register it as a named component through Alpine.data(), which lives in a regular JavaScript file that is not affected by the CSP. The HTML then only references the component by its name, for instance x-data="dropdown", without complex code inside the attribute itself.
// resources/js/components/dropdown.js
// Registered once, outside the CSP-restricted inline attribute
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
items: [],
toggle() {
this.open = !this.open
},
select(item) {
this.selected = item
this.open = false
},
}))
})
<!-- resources/views/dropdown.blade.php -->
<!-- Only a simple identifier reference, no inline JS expression -->
<div x-data="dropdown">
<button x-on:click="toggle">Open menu</button>
<ul x-show="open">
<template x-for="item in items">
<li x-on:click="select(item)" x-text="item.label"></li>
</template>
</ul>
</div>
This relocation has an important side effect: methods like toggle or select are now real JavaScript functions in a regular file, covered by linting, type checking and tests. The Alpine.js CSP build thereby indirectly enforces a cleaner separation between markup and logic, which is a quality improvement even without a strict CSP requirement.
5. What no longer works in the CSP build
The expression parser of the Alpine.js CSP build supports a restricted subset of JavaScript directly inside HTML attributes. Forbidden are arbitrary function definitions as an inline expression, such as x-on:click="() => { doSomething() }", as well as multi-line statement blocks separated by semicolons in more complex forms. Simple method calls, property access, ternary expressions and comparison operators remain allowed.
Complex inline code in x-init should also be avoided in the Alpine.js CSP build and instead implemented as an init() method inside the Alpine.data() definition. The parser is deliberately conservative, because every additional language feature it supports potentially opens a new way to inject code, even if that path is technically no longer eval().
6. Combining a nonce strategy for inline scripts
The Alpine.js CSP build only solves the problem of dynamic expression evaluation, not the general problem of inline <script> tags. Anyone who still needs an inline script for initialization code, for instance to register Alpine.data() components directly in the template, must attach a server-generated nonce attribute to that script, matching the script-src 'nonce-...' value in the CSP header.
// Server-side: generate a fresh nonce per request and reuse it in both
// the CSP header and every inline script tag that must run.
// Content-Security-Policy: script-src 'self' 'nonce-r4nd0mVal123';
<script nonce="r4nd0mVal123">
document.addEventListener('alpine:init', () => {
Alpine.data('counter', () => ({ count: 0 }))
})
</script>
It matters that the nonce value is freshly generated on every request and never hardcoded in the template, otherwise it loses its security value entirely. In most frameworks, for instance Laravel or Symfony, there are middleware packages that generate the nonce automatically and insert it into both the response header and a template variable. The Alpine.js CSP build and the nonce strategy complement each other, because both independently close different attack surfaces.
7. Migrating existing components step by step
Migrating a grown project from the default build to the Alpine.js CSP build works best step by step rather than in one large rewrite. First, the CSP build is tested in parallel in the development environment with an enabled but not yet enforced CSP, for instance through the Content-Security-Policy-Report-Only header, which only logs violations without blocking them.
Every component that triggers a parser error is identified and switched to Alpine.data(). In practice this usually affects a small share of complex components with nested callbacks, while simple x-show and x-model bindings keep working unchanged. Only once report-only mode reports no more violations is the CSP actually enforced in the next step.
8. Common mistakes when switching to the CSP build
The most common mistake is continuing to use complex inline arrow functions in x-on attributes and then wondering why the console reports a parser error that never occurred in the default build. The second common mistake is importing the CSP build but keeping unsafe-eval in the CSP header out of habit, which erases the actual security gain because other scripts on the page are still allowed to use eval.
// WRONG: complex inline arrow function — fails in the CSP build's parser
<button x-on:click="() => { fetch('/api/like').then(r => r.json()) }">
Like
</button>
// RIGHT: named method on an Alpine.data() component
document.addEventListener('alpine:init', () => {
Alpine.data('likeButton', () => ({
async like() {
const response = await fetch('/api/like')
this.liked = await response.json()
},
}))
})
<button x-data="likeButton" x-on:click="like">Like</button>
// WRONG: importing @alpinejs/csp but keeping unsafe-eval in the CSP header
// Content-Security-Policy: script-src 'self' 'unsafe-eval'; ← defeats the purpose
// RIGHT: drop unsafe-eval entirely once migration is complete
// Content-Security-Policy: script-src 'self' 'nonce-r4nd0mVal123';
9. Default build vs. CSP build compared
The decision between the default build and the Alpine.js CSP build depends on the project's security requirements. The following table compares both variants.
| Aspect | Default build | Alpine.js CSP build | Consequence |
|---|---|---|---|
| CSP compatibility | Needs unsafe-eval | Runs without unsafe-eval | CSP build mandatory for strict environments |
| Inline expressions | Arbitrary JavaScript | Restricted grammar | Move complex logic into Alpine.data() |
| File size | Marginally smaller | Marginally larger, own parser | Practically negligible |
| Code organization | Logic often scattered in HTML | Logic centralized in JS files | Better testability as a side effect |
| Migration effort | None, it is the default | Depends on number of complex inline expressions | Start step by step with report-only |
10. Summary
The Alpine.js CSP build makes reactive Alpine components possible even under a strict Content Security Policy without unsafe-eval, by replacing new Function() with its own expression parser. Complex inline logic must be moved into Alpine.data() components for this, which simultaneously improves code organization. Nonce-based inline scripts complement the CSP build for the remaining necessary script tags.
For projects without strict compliance requirements, the default build remains the more convenient choice. But as soon as a security audit, an authority or an industry requirement excludes unsafe-eval, the Alpine.js CSP build is the direct path to keep using Alpine without switching frameworks. Step by step migration with a report-only header minimizes the risk of unexpected breakages in production.
Alpine.js CSP build: the essentials at a glance
Reason
Default Alpine needs new Function() and therefore unsafe-eval. The CSP build replaces that with a safe parser.
Core strategy
Move complex logic out of inline attributes into Alpine.data() components, HTML only as a reference.
Restriction
No inline arrow functions or multi-line statement blocks allowed directly in the attribute.
Combination
Nonce strategy for remaining inline scripts, migration step by step through a report-only header.