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:

notes/package.json
{
  "name": "notes",
  "version": "0.1.0",
  "type": "module",
  "main": "app.js"
}
notes/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:

notes/.nextwebrc
"encrypt": {
  "source": [
    "app.js",
    "lib/**/*.js",
    "lib/**/*.mjs"
  ]
}

Build it:

At the nextweb prompt
pkg create example-esm-notes 0.1.0
You should see
Building 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:

At the nextweb prompt
pkg publish
You should see
Publishing 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-notes
At the nextweb prompt
pkg install example-esm-notes
You should see
Installing example-esm-notes...
...
example-esm-notes 0.1.0 installed successfully

On disk, in the installed package folder, the source is ciphertext and package.json is not:

In your terminal
head -c 60 app.js; echo
You should see
NEXTWEB_ENCRYPTED_V2:zRP6c0QTVcDQQBwl04iDltq+hvU3gNz1b2jmbnG
In your terminal
head -c 60 lib/render.mjs; echo
You should see
NEXTWEB_ENCRYPTED_V2:LX8XOIAULNg1+802sdfzuqP4Do+/mgt8ecbKxTh
In your terminal
grep '"type"' package.json
You should see
  "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:

At the nextweb prompt
deploy notes to notes.example.com using example-esm-notes
You should see
Site "notes" deployed for notes.example.com (template: example-esm-notes).
...
  Status: saved, apply to start it
At the nextweb prompt
apply -y
You should see
...
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                       │
└──────────────────────────────────────────────────────────┘
In your terminal
curl http://127.0.1.10:4001/
You should see
<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:

In your terminal
curl -s http://127.0.1.10:4001/about

curl got no reply at all (exit code 52, an empty reply from the server). The site's log says why:

You should see
--- 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 .mjs and .cjs files, encrypted .js and .json files in an ES module package, and encrypted files that a CommonJS package loads with import() need Nextweb 1.0.0-beta.1.rc.6 or later on the server that runs them. pkg create names the first two; it cannot see the third.
  • Source encryption is turned on for an account on request. Until then pkg create refuses 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.