How to protect ES module source code in Node.js
Require hooks never see import. Module hooks in Node.js, bytecode, single executables, and how Nextweb keeps ES module source encrypted on disk.
· about 6 minutes to read
An ES module is loaded by import, and that changes what a scheme that protects source code has to hook. Loaders that decrypt or rewrite code as it loads were mostly written for require, and an import never reaches them. What Node.js offers today, each part described from its own page as read on 24 October 2026, then how Nextweb encrypts an ES module application, shown from a dry run on production the same day.
The require hooks do not see import
Node.js's CommonJS loader lets a program say how a file extension is read. Its documentation describes require.extensions as the way to "Instruct require on how to handle certain file extensions", and marks it "Stability: 0 - Deprecated" (Node.js modules, v26.12.1). A loader built this way handles require('./lib.js'). An import statement, or import() at run time, goes through Node's ES module loader instead and never calls it. That is why Nextweb's source encryption covered CommonJS only before Nextweb 1.0.0-beta.1.rc.6.
Module customization hooks
Node.js now has one hook for both module systems. module.registerHooks(), "Added in: v23.5.0, v22.15.0" and marked "Stability: 1.2 - Release candidate", registers hooks that "customize Node.js module resolution and loading behavior". Once they are registered, they "will be called for any subsequent import() or require() calls" (Modules: node:module API). Its load hook returns "The source for Node.js to evaluate" with the module's format, so a hook can hand Node the text of a file that is stored in some other form. Synchronous hooks "are run in the same thread and the same realm where the modules are loaded". The older module.register() runs its hooks "in a separate thread", and the same page now marks it "Deprecated: Use module.registerHooks() instead".
A hook is a mechanism, not protection. Whoever writes it decides where the key comes from, and the decrypted text exists in the process that runs it.
Bytecode and single executables
bytenode "truly compiles your JavaScript code into V8 bytecode, so that you can protect your source code". Its README describes what it writes as a CommonJS module: with compileAsModule, "the output will be a commonjs module", loaded with require. Its files "must run with the same Node.js version that was used to compile it".
Node.js's single executable applications now take an ES module. The page says the feature "supports running a single embedded script using the CommonJS or the ECMAScript Modules module system", chosen with "mainFormat": "module", and marks it "Stability: 1.1 - Active development". As with CommonJS, that is packaging: the page does not describe hiding the script. Obfuscation, which leaves the program as JavaScript, is in How to protect Node.js source code with the CommonJS side of this comparison.
How Nextweb encrypts an ES module application
The run below used Nextweb 1.0.0-beta.1.rc.6 (build 172) and nextweb-dev 0.1.7, on the example developer account from the second lesson, with source encryption turned on for it.
The application is four files and a package.json that says it is an ES module:
{
"name": "notes",
"version": "0.1.0",
"type": "module",
"main": "app.js"
}import http from 'node:http'
import { notes } from './lib/notes.js'
import { render } from './lib/render.mjs'
const host = process.env.NEXTWEB_HOST || '127.0.0.1'
const port = Number(process.env.NEXTWEB_PORT || 3000)
http.createServer(async (req, res) => {
if (req.url === '/about') {
// Loaded the first time someone asks, with import()
const { about } = await import('./lib/about.js')
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
return res.end(about + '\n')
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(render(notes))
}).listen(port, host)lib/notes.js exports a list, lib/render.mjs turns it into HTML, and lib/about.js is imported only when someone asks for /about. .nextwebrc names the files to keep encrypted:
"encrypt": {
"source": [
"app.js",
"lib/**/*.js",
"lib/**/*.mjs"
]
}Build it:
pkg create example-esm-notes 0.1.0Building example-esm-notes 0.1.0 for example (internal)...
Built and registered dist/example-esm-notes_0.1.0_all.zip.enc
owner example (you)
files 4 app files; 4 source file(s) encrypted
package key akek-2604fb770018
source key akek-574f0e6463bc
Servers need Nextweb 1.0.0-beta.1.rc.6 or later to run this package: it encrypts .mjs or .cjs files, or .js or .json files in an ES module package.
sha256 8b9bbc3c0e559771e73e4174ec5fd610ebd60f69659b3c302b169a66b55e638d
size 4489 bytes
Next: pkg validate, then pkg publish.All four files are encrypted, the .mjs one included. The line before sha256 is the version a server needs: an older Nextweb still installs the package, and the application fails when it imports an encrypted file.
Publish it, and install it by name:
pkg publishPublishing example-esm-notes-0.1.0-all to https://cdn.nextwebjs.com...
Published: example-esm-notes-0.1.0-all (4489 bytes, sha256 8b9bbc3c0e559771e73e4174ec5fd610ebd60f69659b3c302b169a66b55e638d)
It installs only on your own license: pkg install example-esm-notespkg install example-esm-notesInstalling example-esm-notes...
...
example-esm-notes 0.1.0 installed successfullyOn disk, in the installed package folder, the source is ciphertext and package.json is not:
head -c 60 app.js; echoNEXTWEB_ENCRYPTED_V2:zRP6c0QTVcDQQBwl04iDltq+hvU3gNz1b2jmbnGhead -c 60 lib/render.mjs; echoNEXTWEB_ENCRYPTED_V2:LX8XOIAULNg1+802sdfzuqP4Do+/mgt8ecbKxThgrep '"type"' package.json "type": "module",
"type": "application",package.json stays readable because Node reads it to decide how to load the application, before a key exists. The second type line is the nextweb block pkg create adds.
Deploy it with the same commands as any Node.js application:
deploy notes to notes.example.com using example-esm-notesSite "notes" deployed for notes.example.com (template: example-esm-notes).
...
Status: saved, apply to start itapply -y...
Service notes: running example-esm-notes 0.1.0 (manage with "service notes status" / "service notes restart")
Visit: http://127.0.1.10:4001/
...
┌──────────────────────────────────────────────────────────┐
│ notes is live → http://127.0.1.10:4001/ │
│ verify: passed · applied in 4.8 s │
└──────────────────────────────────────────────────────────┘curl http://127.0.1.10:4001/<h1>Notes from an encrypted ES module</h1>
<ul><li>Encrypted on disk</li><li>Decrypted as Node loads it</li><li>Checked before it runs</li></ul>The page comes from app.js, lib/notes.js and lib/render.mjs, decrypted in memory as Node loaded them. None of the page's text is in the installed files.
Then one changed byte. lib/about.js had not been loaded yet, so the run changed one byte of the installed file and asked for /about, which imports it:
curl -s http://127.0.1.10:4001/aboutcurl got no reply at all (exit code 52, an empty reply from the server). The site's log says why:
--- 2026-10-24T07:12:42.445Z notes started ---
INTEGRITY VIOLATION
Modified file: lib/about.js
--- 2026-10-24T07:12:53.484Z notes started ---Each encrypted file is checked against the package's file list before it loads, an import() at run time included, and a changed file stops the process. Nextweb then starts the site again.
What it does not do
- Code is decrypted on the machine that runs it, so whoever controls a licensed server can extract what its license opens. Encryption makes a license the cheapest way to get your software; it does not make copying impossible.
- Encrypted
.mjsand.cjsfiles, encrypted.jsand.jsonfiles in an ES module package, and encrypted files that a CommonJS package loads withimport()need Nextweb 1.0.0-beta.1.rc.6 or later on the server that runs them.pkg createnames the first two; it cannot see the third. - Source encryption is turned on for an account on request. Until then
pkg createrefuses a build that asks for it. - The key comes from our license server. Nextweb checks its license each time it starts, and a package installs only on your own license or on an organization's members and linked servers. Package DRM has the full description, the limits included.
Next step
The second lesson packages a small Node.js application, installs it by name, and deploys it. To build an ES module application the same way, put "type": "module" in its package.json and name its files under encrypt.source, as the .nextwebrc guide describes.
Questions
Does Nextweb's source encryption work with import()?
Yes, from Nextweb 1.0.0-beta.1.rc.6. Nextweb decrypts an ES module in memory as Node loads it, through import or import(), the same way it decrypts CommonJS loaded by require. A changed file stops the process.
Which files can be encrypted?
Files ending in .js, .mjs, .cjs and .json, except package.json, which Node reads to decide how to load the application before a key exists.
What happens on an older Nextweb?
It installs the package, and the application fails when it imports an encrypted file. pkg create prints the version a server needs when it encrypts .mjs or .cjs files, or .js or .json files in an ES module package.
Related: How to protect Node.js source code, Package DRM, The .nextwebrc file.