Node.js 20: Permission Model and Test Runner Stability
Node.js has always operated on a trust-everything model. Your code, your dependencies, your dependencies’ dependencies — they all get the same unrestricted access to the filesystem, network, and child processes. A malicious package in your node_modules can read your SSH keys, exfiltrate environment variables, or spawn arbitrary processes. No guardrails. No questions asked.
Node.js 20, released April 18, starts changing that.
The Permission Model #
The new --experimental-permission flag lets you restrict what your Node.js process can do at runtime. Want to prevent filesystem writes outside a specific directory? Done. Want to block child process spawning entirely? Also done. Worker thread creation? Controllable.
node --experimental-permission --allow-fs-read=/app --allow-fs-write=/app/tmp server.jsThis is conceptually similar to Deno’s permission model (which has had this since day one), but the implementation differs. Node’s version is process-level, not module-level — you can’t grant different permissions to different dependencies. It’s a blunt instrument, sure. But a blunt instrument beats no instrument.
The security implications are significant. Supply chain attacks through compromised npm packages have been a recurring nightmare. The event-stream incident (2018), ua-parser-js (2021), colors and faker (2022) — each one exploited Node’s permissive execution model. A permission-restricted Node.js process would’ve contained the blast radius of every single one.
It’s experimental, so I wouldn’t deploy it to production tomorrow. But the direction is right. The Node.js security model needed to evolve past “hope your dependencies aren’t malicious.”
Stable Test Runner #
The built-in test runner (node:test) moved from experimental to stable. This one’s been a long time coming.
For years, the JavaScript testing landscape has been fragmented across Jest, Mocha, Vitest, Tape, AVA — each with its own configuration, assertion library preferences, and quirks. I’ve used most of them (Tape and Jest most heavily; AVA at a couple of startups). The friction of choosing, configuring, and maintaining a test framework is real. For smaller projects, the test setup sometimes takes longer than writing the actual tests.
node:test ships with describe, it, and test functions, lifecycle hooks (before, after, beforeEach, afterEach), and subtests. It’s not trying to replace Jest’s snapshot testing or Vitest’s HMR integration. It’s providing a baseline: if you need to write tests for a Node.js project, you can do it with zero external dependencies.
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('math', () => {
it('adds numbers', () => {
assert.strictEqual(1 + 1, 2);
});
});Run it with node --test. That’s it. No config file, no babel transforms, no jest.config.js with 47 options.
Reporters and code coverage are still experimental, so if you need those today you’ll want to stick with your existing setup. But for utility libraries, internal tooling, and quick prototypes — node:test is now a viable default.
The V8 Update and Other Changes #
V8 11.3 brings the usual performance improvements and new JavaScript features. import.meta.resolve now works synchronously, which simplifies module resolution in ESM codebases (no more await import.meta.resolve('./foo.js') when you just want a path).
The URL parser was replaced with Ada 2.0, which is meaningfully faster. If your application does heavy URL parsing — API gateways, web crawlers, redirect handlers — you’ll see real throughput improvements without changing any code.
The Bigger Picture #
Node.js 20 is slated for LTS promotion in October. Between the permission model, the stable test runner, and the V8 upgrade, it’s one of the more consequential releases in recent memory.
The permission model is the headline for me. Not because it’s ready for production use today (it isn’t), but because it signals a philosophical shift. Node.js acknowledging that “trust everything by default” was a design flaw, not a feature — that matters. Future versions will build on this foundation, and eventually we’ll have granular, production-grade permission controls.
Security that’s built into the runtime beats security that’s bolted on after the fact. Every time.