NW.js Preload Scripts: The Security Boundary You're Probably Missing
NW.js blends Node.js and Chromium in one renderer, making preload scripts the only security boundary. Context isolation moves preloads to an isolated world, but native addons, webview inheritance, and Electron API gaps create real migration friction.
31 Jul 2025, 22:35 UTC

The Blended Runtime Problem
NW.js (formerly Node-Webkit) runs Node.js and Chromium in a single renderer process. Unlike Electron's split main/renderer architecture, the page context gets direct access to require, process, and Buffer by default. This design makes it trivial to call fs.readFileSync from a <script> tag, but it also means any cross-site scripting (XSS) vulnerability instantly becomes remote code execution (RCE) on the user's machine.
The security boundary lives in the preload script—a piece of code that executes before the web page loads. In the default non-isolated mode, the preload shares the exact same JavaScript global object as the page. That means window.api = { readFile: ... } works, but so does Object.prototype.polluted = true leaking bidirectionally between preload and page.
Context Isolation Changes the Game
Enabling context_isolation (via manifest flag or --context-isolation CLI) moves the preload into a separate isolated world—a distinct JavaScript context that shares the DOM but not the global prototypes. The preload still runs in the renderer process (not a separate utility process like Electron), but it can no longer touch window or Object.prototype directly.
To expose APIs safely, you must use contextBridge.exposeInMainWorld(), which serializes values across the boundary using structured clone. Functions become callable proxies; plain objects are copied. This prevents prototype pollution and forces explicit API design.
Worked Example: Secure File Read API
Create package.json with the secure baseline:
{
"name": "secure-nw-app",
"main": "index.html",
"nodeIntegration": false,
"context_isolation": true
}Preload script preload.js:
const { contextBridge } = require('electron');
const fs = require('fs').promises;
contextBridge.exposeInMainWorld('files', {
async readText(path) {
// Validate path stays within app directory
const resolved = require('path').resolve(process.cwd(), path);
if (!resolved.startsWith(process.cwd())) {
throw new Error('Path traversal blocked');
}
return fs.readFile(resolved, 'utf8');
}
});Renderer page index.html:
<script>
// No require(), no process, no Buffer
window.files.readText('config.json')
.then(console.log)
.catch(console.error);
</script>Launch with nw . --context-isolation (or rely on manifest). The page calls window.files.readText() but cannot access require('child_process') or mutate Array.prototype.
Where the Model Breaks Down
Three practical limitations bite teams migrating from the legacy model:
- Native addons require exact version rebuilds. A
.nodemodule compiled for Node 18 fails on NW.js 0.78 (Node 16). You must runnw-gyp rebuild --target=0.78.0 --arch=x64 --dist-url=https://nwjs.io/versionsfor every native dependency. - Webview inheritance is surprising. A
<webview preload="./preload.js">inherits the main window'snodeIntegrationandcontextIsolationsettings unless you explicitly override them withnodeintegration="false" contextisolation="true"attributes on the tag itself. - Core module availability differs from Electron.
require('electron')returns undefined in NW.js preloads unless you polyfill it. ThecontextBridgeAPI is borrowed from Electron but implemented by NW.js internally—check the changelog for your version to confirm parity.
Verification Checklist
- Load an untrusted HTTPS page in a
<webview preload="./preload.js" nodeintegration="false" contextisolation="true">and confirmtypeof require === 'undefined'in DevTools console. - Inject
Object.prototype.polluted = truein a non-isolated preload; verify the page sees it. Then enablecontext_isolationand confirm the pollution is blocked. - Run your native addon rebuild against the exact
process.versions.nwjsstring reported at runtime.
If you're shipping an NW.js app today with nodeIntegration: true and no context isolation, any XSS in loaded content owns the user's machine. The fix is a manifest change and a preload rewrite—do it before your next release.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.