Get E-Book
Child Processes & OS Workloads

Shell Injection and Safe Process Launching

Ishtmeet Singh @ishtms/June 11, 2026/30 min read
#nodejs#child-process#shell#spawn#execfile#security

A shell-backed child process can completely change what a string means. And yeah, that's pretty much the entire problem of this chapter only.

You have some string in Node. Maybe it came from process.argv, an HTTP request, a queue message, a filename, whatever. Node passes that string to a shell, and now the shell gets to parse it as command language before your actual program has even started. So characters which were totally boring inside JavaScript suddenly mean something to the shell.

Take this -

js
const { exec } = require("node:child_process");

const value = process.argv[2];

exec(`printf "%s\n" ${value}`, (err, stdout) => {
  process.stdout.write(stdout);
});

Run it with -

text
hello

and yeah, you get -

text
hello

Nothing surprising till now. But on a Unix-like system, try giving it -

text
hello; uname -a

The shell doesn't see that whole value as one argument anymore. It sees the semicolon as shell syntax only. So printf runs first, and then uname -a runs after it, just like that.

Your JavaScript had one string. But the shell parsed that string and decided there are two commands sitting inside it.

And exec() is not doing anything weird here, by the way. This is exactly what exec() is supposed to do i.e it starts a shell and asks that shell to execute a command string. The problem happened earlier itself, when we built that command string using user-controlled data.

Now look at this version -

js
const { execFile } = require("node:child_process");

const value = process.argv[2];

execFile("printf", ["%s\n", value], (err, stdout) => {
  process.stdout.write(stdout);
});

This one behaves differently.

value is one argument now. If it contains a semicolon, the semicolon stays inside that argument only. There is no shell sitting in between and reading it as command syntax.

So if the value is -

text
hello; uname -a

then printf receives those characters as plain data.

So that's the main idea for this chapter - once you involve a shell, you're no longer passing only arguments around. You're passing text into another language parser.

Where the shell gets involved

A shell is also just another program, okay? It reads command text, parses it, performs whatever expansions its grammar supports, and then starts programs.

On Unix-like systems, exec() normally uses /bin/sh by default. On Windows, Node uses whatever command processor process.env.ComSpec is pointing to.

And a shell command string can contain much more than one program name and some arguments.

For example -

js
exec("cat access.log | grep 500 | wc -l", (err, stdout) => {
  console.log(stdout.trim());
});

That entire string is shell language. cat, grep and wc are programs, but the | belongs to the shell only. The shell reads the command, sees the pipes, starts those programs, connects their stdin and stdout streams, and runs the pipeline. That's why shell-backed commands are so convenient - redirects, pipes, variable expansions, multiple commands, all inside one string.

Of course the same feature becomes a problem the moment data you don't fully control goes inside that string.

Characters like ;, |, >, <, $, quotes, spaces, *, ?, parentheses, backticks and friends may all carry special meaning, depending on which shell you're using and where they appear.

For example, a semicolon can finish one command and start another -

sh
echo hello; whoami

A pipe can send stdout from one process into another -

sh
cat file.txt | grep error

A redirect can change where output goes -

sh
echo hello > output.txt

And something such as -

sh
$(id)

can cause the shell to execute another command and insert its output. Just like that.

The shell does all of this parsing before your actual program receives anything.

So a shell-backed launch goes more like this -

text
JavaScript creates command text
  -> shell parses that text
  -> shell performs expansions
  -> shell starts the program with argv

And a direct launch is just -

text
executable + argv
  -> process creation

No shell parsing your argument values in between. You still get normal process behavior of course - PATH lookup can still happen, the child still has its environment, working directory, stdin, stdout, stderr, exit codes, all that. But a semicolon inside one argument is not gonna suddenly start another command.

argv keeps the arguments separate

You have probably seen spawn() already -

js
const { spawn } = require("node:child_process");

const needle = process.argv[2];

const child = spawn("/usr/bin/grep", [
  "-R",
  needle,
  "./logs"
]);

child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);

What's nice here is the command and every argument already have their own separate place.

If needle contains -

text
error; rm -rf ./tmp

then as far as Node is concerned you still have -

text
program = /usr/bin/grep

argv[1] = -R
argv[2] = error; rm -rf ./tmp
argv[3] = ./logs

The semicolon is still sitting inside the search pattern only. No shell reads it. grep receives the string and decides what it means as a grep pattern. That's grep's headache now, not ours.

Same thing with execFile() also -

js
const { execFile } = require("node:child_process");

execFile(
  "/usr/bin/grep",
  ["-R", needle, "./logs"],
  (err, stdout) => {
    if (err) throw err;
    process.stdout.write(stdout);
  }
);

The main difference between these two is not argument safety, by the way. Both let you pass executable and arguments separately. The difference you'll actually care about is output handling.

spawn() gives you streams, so you can process stdout and stderr while the child is still running.

execFile() normally buffers the output and hands it to your callback when the process finishes.

So if you're expecting small output, execFile() is very convenient. If the child can print a lot of data, spawn() is the better fit, otherwise you're sitting around collecting all of it into memory for no reason.

And for values coming from outside your program, passing them as complete argv entries should be your default. A filename? Argument. A search string? Argument. Commit SHA? Argument. User ID? Argument. You don't need to convert those values into pieces of shell code first. Please don't.

Sometimes stdin is even better

Not every value has to go into argv either.

Say you need to send a large chunk of text into another program -

js
const child = spawn("/usr/bin/sort", [], {
  stdio: ["pipe", "pipe", "pipe"]
});

child.stdin.end(userText);
child.stdout.pipe(process.stdout);

Now userText goes into the child's standard input. The command itself stays fixed, and there are zero user-controlled command-line arguments.

This is also handy for sensitive values, because argv can show up in places you weren't really thinking about i.e process listings, logs, crash reports, debugging tools, monitoring systems, all of that. Anyone on the same machine can run ps and read your arguments, so there goes your secret token.

If a program accepts its actual input through stdin, sending the data there is cleaner than stuffing everything into command-line arguments.

Shell injection and argument injection are different problems

This part is easy to mix up, because both problems hang around the same child-process code.

Consider this -

js
const { exec } = require("node:child_process");

const file = process.argv[2];

exec(`tar -tf ${file}`, (err, stdout) => {
  console.log(stdout);
});

If file contains -

text
archive.tar; id

the shell interprets that semicolon and happily runs another command. That's shell injection.

Now change it to -

js
const { execFile } = require("node:child_process");

const file = process.argv[2];

execFile("tar", ["-tf", file], (err, stdout) => {
  console.log(stdout);
});

No shell is parsing file here, so the semicolon stays inside one argv entry only. Good. But we're not done thinking yet!

The tar program itself also parses arguments. Say a user-controlled filename starts with something tar recognises as an option. Now the shell is not causing any problem at all. tar receives the argument exactly how Node sent it, and then tar itself gives that argument special meaning. That's argument injection.

So direct argv handling solves one problem, which means shell syntax from your value doesn't get executed by a shell. What it doesn't solve is the child program having its own argument grammar.

A lot of Unix programs support --, which means "options are finished, treat whatever comes after this as positional input".

So you may end up writing -

js
execFile("tar", ["-tf", "--", file], onDone);

Whether that works depends on the program you're calling. -- belongs to the target program's argument parser, not Node. This is why you still have to understand the CLI you're launching. Direct process creation stops shell parsing. It cannot stop the child program from interpreting its own arguments.

The dangerous code usually looks totally normal

This kind of code doesn't look dramatic -

js
const cmd = `convert ${input} ${output}`;
exec(cmd, done);

And that's probably why people keep writing it.

But both input and output are now part of shell command text. Spaces in there? Shell may split it differently. Quotes? They affect parsing. $(...)? Command substitution. *? Filename expansion. >? Your output goes somewhere else now. Quite a lot of parsing is happening before convert has even started, no?

Compare that with -

js
const args = [input, output];
const child = spawn("/usr/local/bin/convert", args);

Now the executable is one value and the arguments are separate values. Can convert still reject those arguments? Sure. Weird option behavior? Still possible. It can still read or overwrite files depending on what you gave it. But shell command parsing is out of the picture now, and most of the time that's exactly what you want.

What about escaping?

This is usually where somebody asks, "okay, but can't I simply escape the user input?"

Sometimes, yes.

But shell escaping is not one universal operation, my friend. The correct escaping depends on which shell you're using, where the value sits inside the command, which quoting form is around it, what platform you're on, and sometimes even how the command gets edited later.

POSIX sh quoting is not PowerShell quoting. PowerShell is not cmd.exe. Bash has features plain sh doesn't necessarily have. And even inside one shell, escaping a value inside double quotes and escaping a fully unquoted value are two different jobs.

So if you don't actually need shell syntax, don't introduce shell syntax and then spend your afternoon trying to safely escape your way out of it.

This is much easier to review -

js
spawn(file, args, options);

The executable lives in file. Arguments live in args. Process settings live in options. And values from users or requests occupy complete argument entries instead of getting mixed into command text.

If you genuinely need a shell because you're deliberately using pipes, redirects, shell variables, whatever - fine. Just recognise that you're writing shell language at that point, and treat every inserted value accordingly. You're on your own there.

What Node actually launches

Now let's go a little lower, because this clears up a few confusing things about these APIs.

spawn() is the most direct child-process API you'll commonly use -

js
spawn(command, args, options);

Node takes the command, argument array and options, validates them, prepares things like the environment, working directory and stdio configuration, then asks its native process code to create the child. Below Node's JavaScript implementation, libuv handles the platform-specific process creation work.

On Unix-like systems, programs eventually start with an executable and an argument vector. Those arguments are already separate strings by the time the target process receives them.

So if one argument contains -

text
hello; whoami

the semicolon has no special process-launch meaning there. It's just part of that argument. What happens after that depends on the target program - it might treat that text as a filename, a regex, or some mini-language of its own. But the OS is not running whoami just because it saw a semicolon in argv.

Windows works differently inside. Windows process creation uses a command-line string, so Node has to construct that command line from the args you gave. Then the child process, usually through its runtime or its own parser, converts that command line back into argv-style values. That itself is one reason Windows argument handling has all these platform-specific quirks.

Node also exposes windowsVerbatimArguments. Enable it and Node stops doing its normal Windows argument quoting for you, which means now you are personally responsible for how those arguments look. On Unix-like systems that option does nothing at all. It just sits there.

execFile() still uses direct process creation by default

execFile() sometimes sounds like some totally different process mechanism, because the API looks closer to exec(). But by default it launches a file directly with an argument array, same idea as spawn().

js
execFile(file, args, options, callback);

The big convenience is it collects stdout and stderr for you and gives them to the callback. Something like -

js
execFile("git", ["status", "--short"], (err, stdout, stderr) => {
  if (err) {
    console.error(stderr);
    return;
  }

  console.log(stdout);
});

Nice for small output. But because that output is buffered, there's a limit - Node has the maxBuffer option for this reason only. If the child can produce huge output, streaming it with spawn() makes more sense, otherwise your callback gets a truncated buffer, an error, and a bad day.

So I separate them like this in my head - spawn() when I want the child streams while it's running, execFile() when I want to run one executable directly and collect a reasonably sized result afterwards.

Both can avoid a shell.

exec() adds the shell

exec() takes a different input -

js
exec(command, options, callback);

That command is one string because the shell is the one supposed to parse it. Node starts the shell, hands it your command string, and then the shell decides which programs to run.

This also changes which process Node is directly managing. Say you write -

js
exec("cat file.txt | grep error", callback);

Node starts the shell. Then the shell starts the programs for that pipeline. So now more than one process is involved - cat and grep were started as part of the shell command, but Node's immediate child is the shell process itself.

This becomes pretty important once you're dealing with signals, timeouts, termination and process cleanup, because killing the immediate shell process and killing every process started through it are not always the same operation. (Send SIGTERM to a plain sh and very often the children just keep running. Surprise!)

Exit codes also follow shell behavior. For a simple command, you may just see the program's exit status coming back through the shell. For pipelines, redirects, built-ins and other complicated command text, the shell's own rules decide the final status.

Direct launches are easier to reason about here. If -

js
spawn("some-program", args);

fails with ENOENT, Node couldn't find or start the executable, that's one thing. If the program starts fine and later exits with code 2, that's a different thing - process creation worked, the program itself ran and reported failure. Log these two differently please, they are telling you very different stories.

shell: true changes the rules again

You can also ask spawn() to use a shell -

js
spawn("echo hello", {
  shell: true
});

Now you're back to shell parsing, sorry.

And this is one detail that's very easy to miss, because spawn() normally makes people think "arguments are separate, so I'm safe". Look at this -

js
spawn("echo", [name], {
  shell: true
});

See, it has an args array. Looks pretty safe at first glance only. But once shell: true is there, those values have to participate in shell command construction all over again.

In Node v24, passing an args array to spawn() or execFile() while shell is enabled is runtime-deprecated under DEP0190. Node warns about this because those separate values don't behave like some automatic shell-escaping system. There is no such system, basically.

So don't look at an args array and stop reviewing the call. Check whether a shell is switched on first, then relax.

Without one -

js
spawn("echo", [name]);

name is an argument, one array entry, done. With one -

js
spawn("echo", [name], { shell: true });

shell command parsing has come back into the operation. Same array, completely different meaning.

If you intentionally need shell behavior, then make that pretty obvious in the code -

js
spawn('printf "%s\n" "$HOME"', {
  shell: true
});

Here shell variable expansion is actually the reason we're using a shell, so okay, fine, allowed.

But if name, filename, branch, query, or some other outside value only needs to reach a normal command-line argument, I would keep the shell fully out of it. What's the benefit of converting safe separate data into shell text and then sitting around worrying how that text gets parsed? None. That's the benefit.

For most child-process code, the boring version is the one I want to see -

js
spawn(program, args, options);

Pick the program with some thought, keep arguments separate, use stdin when that's a better input channel. And separately, check how the target program itself interprets the arguments you gave it - that part is the program's parsing, not Node's. Do all this and the code becomes much easier to reason about, because now you are dealing with one parser less.

PATH, cwd, and env

Even when you launch a program directly, some lookup is still happening if you only give Node a command name.

js
spawn("git", ["status"]);

Where did that git actually come from? Node asks the OS to find it using PATH.

And this is one of those things which works perfectly on your laptop, then you deploy the exact same code somewhere else and suddenly ENOENT. Classic. Your local shell has a big fat PATH maybe - git comes from /usr/bin/git, or Homebrew put it somewhere else, or some version manager changed things, who knows. A service manager can start your process with a different PATH. Containers usually have a much smaller one. Test runners sometimes insert temporary directories full of shim executables also.

So spawn("git", ["status"]) doesn't really mean "run this exact Git executable". It means "find something called git using the child's PATH rules, then run that". Slightly scary when you write it out like that, no?

And it can be a real problem if PATH contains directories somebody can write to, project-local directories, or just directories you didn't expect to be there. The wrong executable can get found before the real one.

Using an absolute path removes that lookup -

js
const child = spawn("/usr/bin/git", ["status"], {
  cwd: "/srv/app",
  env: { PATH: "/usr/bin:/bin" },
});

Now /usr/bin/git is the executable, no searching. cwd says which directory the child starts in, and env says which environment variables the child gets.

One thing people fully miss with env - you're not adding a couple of variables to the current environment. You're giving Node a replacement environment object, full stop. So this -

js
spawn("/usr/bin/git", ["status"], {
  env: {
    PATH: "/usr/bin:/bin",
  },
});

means the child gets that PATH, but it doesn't automatically get all the other variables from process.env. Everything else is gone also.

Node v24 has a slightly weird PATH case here, believe me. If you provide options.env, command lookup uses options.env.PATH. On Unix, if that env object doesn't contain PATH at all, lookup falls back to /usr/bin:/bin. Windows behaves differently and uses the current process PATH for lookup. Consistency, as usual.

That can give you a very local-machine-looking failure. Say your Node executable lives in /opt/homebrew/bin/node -

js
spawn("node", ["tool.js"], { env: {} });

On macOS, that can fail because the fallback PATH doesn't include /opt/homebrew/bin.

So if the child needs PATH, give it one. Simple as that.

js
const env = {
  PATH: "/usr/local/bin:/usr/bin:/bin",
  LANG: "C.UTF-8",
};

spawn("/usr/bin/git", ["status"], {
  cwd: repo,
  env,
});

Same for the other environment variables also. Child needs HOME, TMPDIR, some proxy variable, locale settings, whatever - put it there. Child doesn't need credentials, debug flags, package-manager hooks, NODE_OPTIONS, random variables inherited from the parent? Then don't hand them over just because they happened to exist. The environment is input to the child process, so treat it as part of the launch configuration itself.

Sometimes you do want almost the whole parent environment, but with one thing removed. This is common with NODE_OPTIONS -

js
const env = {
  ...process.env,
  NODE_OPTIONS: undefined,
};

spawn(process.execPath, ["tool.js"], { env });

Node doesn't include undefined values in the child environment.

Handy, yes. But if you're writing tighter process-launch code, I would usually start from an empty object and add whatever the child actually needs, instead of copying everything and deleting one or two suspicious-looking variables. Copy-all-then-delete is how surprises stay alive.

cwd has another effect too - relative executable paths are resolved from that directory.

js
spawn("./scripts/build", ["--prod"], {
  cwd: "/srv/app",
});

Here Node tries to run /srv/app/scripts/build.

And sometimes that's exactly the contract you want, nothing wrong there. Build scripts often work this way because the repository directory is already trusted and known.

For code launched from requests, workers, or anything running with more access than usual, I'd rather be boring - fixed executable path, fixed working directory, explicit environment. Less guessing later.

The shell option

spawn() normally launches the executable directly because shell defaults to false.

But you can ask Node to run the command through a shell -

js
const child = spawn("echo $SHELL && pwd", {
  shell: true,
});

Now that whole string is shell input.

On Unix systems the default shell is usually /bin/sh. On Windows it comes from process.env.ComSpec. You can also choose one yourself -

js
spawn("set -eu; ./scripts/release.sh", {
  shell: "/bin/sh",
});

Why would anybody use a shell at all? Because some things are just shell syntax, that's why. Pipes, redirections, command substitution, shell variables, built-ins... Node is not going to interpret those for you in a direct process launch. So if your command really is shell code, using a shell is fine. No shame in it.

The trouble starts when outside data gets mixed into the shell string.

js
const branch = process.argv[2];

spawn(`git log --oneline ${branch}`, {
  shell: true,
});

Now branch is not just an argument anymore, okay - the shell gets to parse it. Whatever is inside branch is now shell grammar. Sleep well.

You can start escaping values, sure. But now your quoting logic has to be correct for that shell and for the exact place where the value is inserted. Then somebody edits the command six months later, adds another variable in another position, and your old escaping assumption may not apply there anymore. It will not announce this, obviously.

If the value is supposed to be an argument to Git, just make it an argument to Git -

js
const branch = process.argv[2];

spawn("git", [
  "log",
  "--oneline",
  "--",
  branch,
]);

Node keeps those entries separated when it launches the process.

The -- in this example is Git syntax, not Node syntax. Git uses it to stop treating what follows as normal options in that position. Other programs have their own parsing rules, so check the program you're launching.

If shell syntax is genuinely required, keep the command string built only from values you control. Variable data can usually travel through stdin, files, fixed environment variables, or a separately launched process instead.

And if you do end up shell-escaping, don't treat "escaped once" as some universal certificate. Escaping depends on which shell parses the string and where that value sits in the shell grammar. Change either one and your escaping story changes also.

There's another annoying difference when things fail. Suppose your shell path itself is wrong -

js
spawn("echo hello", {
  shell: "/does/not/exist",
});

Node can't even start the shell.

But suppose /bin/sh is fine and the command inside it is just misspelled. Now the shell starts successfully, and then the shell reports it couldn't find the command - you'll usually see that through stderr and the shell's exit status.

Two different failures, in two different places. So make your logging good enough to tell them apart, otherwise you'll be debugging the wrong half of the problem forever.

Windows launch rules

Windows makes process arguments more... interesting. I wanted to use a stronger word, but let's keep it polite.

On Unix, the process-start call receives an argv array, so there's already a clean separation between argument zero, argument one, argument two, and so on. Windows process creation instead receives a command-line string, and then the program being started parses that string into arguments itself. And different programs don't even necessarily parse it the same way! Programs using the normal C runtime follow one set of backslash and quote rules. cmd.exe has its own parsing. PowerShell has another one. Jeez.

Node hides a lot of this when you use normal direct spawn() or execFile() calls. You give Node an args array -

js
spawn("tool.exe", ["--name", "a b"]);

Node builds the Windows command line for you. Usually that is exactly what you want, so say thanks and move on.

There is an option called windowsVerbatimArguments -

js
spawn("tool.exe", ['--name=a b'], {
  windowsVerbatimArguments: true,
});

When you turn that on, Node stops doing its usual argument quoting for you. Congratulations, now YOU are responsible for producing arguments in the exact form the target program expects. Unix ignores this option completely, and Node can also switch it on automatically in some shell-backed Windows launches.

I would avoid turning it on manually unless you have some very specific Windows program which requires its own command-line format, and you know exactly why Node's normal quoting is not enough. If you can't explain why, you don't need it.

Batch files are another special case.

.bat and .cmd files are interpreted by the Windows command processor. They're not the same as launching a normal .exe.

You can make that explicit -

js
const bat = spawn("cmd.exe", [
  "/d",
  "/s",
  "/c",
  script,
], {
  windowsHide: true,
});

/c tells cmd.exe to run the command and exit. /d disables AutoRun commands, and /s changes some quote handling used around /c.

Yeah, Windows shell quoting gets weird quickly.

Script paths containing spaces need cmd.exe quoting, and user-controlled values passed into batch commands need extra attention, because cmd.exe gets to parse them before the batch file even sees anything. Two parsers before your code even runs.

If this code is reachable from requests and you can avoid the batch file entirely, a real executable plus a normal args array is much easier to reason about.

Launching an .exe directly is much nicer -

js
spawn(
  "C:\\Program Files\\Git\\cmd\\git.exe",
  ["status"],
  {
    windowsHide: true,
  },
);

Windows still receives a command line eventually because that's how process creation works there, but Node handles constructing it and your code still keeps working with an args array.

Windows environment variables have another oddity also, i.e. the names are case-insensitive. So PATH, Path and path all refer to the same environment variable from the child's point of view - even though a JavaScript object can happily contain all three keys. Node sorts environment keys and picks one case-insensitive match. So if your object contains both PATH and Path, you may not get the value you thought you were sending.

For cross-platform helpers, normalize this and keep one PATH key only. One key, one value, no drama.

Sometimes you really do need platform-specific process launch code -

js
const isWin = process.platform === "win32";

const file = isWin
  ? "cmd.exe"
  : "/usr/bin/git";

const args = isWin
  ? ["/d", "/s", "/c", "git status"]
  : ["status"];

That's okay, that's allowed. I'd much rather see the Windows branch sitting there in plain sight than have cmd.exe parsing hidden inside some generic helper where everyone forgets it's happening. Visible weirdness is better than invisible weirdness.

Bounding child execution

Avoiding shell parsing doesn't stop a child process from causing other problems, don't think that. You can launch a completely trusted executable and it can still run forever, wait forever on stdin, keep writing output, or produce so much buffered output that your parent process gets into real trouble.

Node has timeout for process lifetime -

js
const child = spawn(process.execPath, ["worker.js"], {
  timeout: 30_000,
  killSignal: "SIGTERM",
});

After 30 seconds, Node sends the configured signal. Default is SIGTERM.

But please don't read timeout as "this process is definitely gone after 30 seconds". Node sends the signal - what happens after that depends on the process and the operating system. A Unix process may catch SIGTERM, do its cleanup, and exit later at its own sweet time. It can also ignore signals which it's allowed to ignore, because Unix allows that. And if the process launched more processes, killing the one Node directly started doesn't automatically mean every descendant also disappears. Children of children have their own opinions.

So timeout gives you a point where Node starts termination. It is not full process-tree cleanup. Different promises entirely.

exec() and execFile() have another limit because they collect stdout and stderr in memory, i.e. maxBuffer.

js
execFile(file, args, {
  maxBuffer: 1024 * 1024,
  timeout: 10_000,
}, done);

If buffered output crosses the configured limit, Node terminates the child according to that API's behavior. In Node v24 the default maxBuffer is one MiB.

And stdout and stderr get their own separate buffer limits also - it's not one combined pool.

Unicode makes this slightly confusing when you're debugging, because JavaScript string length and actual output byte count are not always the same number. maxBuffer is about the bytes coming through those pipes, so count bytes, not string lengths.

spawn() is different because stdout and stderr are streams there, and there is no maxBuffer option for normal streaming output. If you want a byte limit, count the bytes yourself while draining -

js
let seen = 0;

child.stdout.on("data", chunk => {
  seen += chunk.length;

  if (seen > 1_000_000) {
    child.kill("SIGTERM");
  }
});

And notice - we're still reading the stream while counting it. Don't stop reading to go count, keep both going.

If the parent never reads from a piped stdout or stderr at all, the child can eventually block once the OS pipe buffer fills up. And now you've got a process that looks mysteriously stuck, even though the original launch code was perfectly valid. Extremely fun bug, the first time.

If you don't need the output, say so -

js
spawn(file, args, {
  stdio: ["ignore", "ignore", "ignore"],
});

If you do need it, drain it. That's the whole rule.

So for the buffered APIs like exec() and execFile(), you're usually thinking about maxBuffer, timeout and killSignal. With spawn() you're thinking about who drains the streams and how many bytes you'll accept, plus the same timeout and killSignal also.

And these controls solve different problems than shell safety, please note. Passing args directly avoids shell interpretation - that's one problem. Output and time limits stop trusted programs from eating unlimited local resources - that's another problem. A single call can need both, and often does.

Launch privileges

A child process also gets an operating-system identity.

On POSIX systems, Node lets you set uid and gid -

js
spawn("/usr/bin/convert", [input, output], {
  uid: 10001,
  gid: 10001,
});

The child runs using that user ID and group ID, assuming the parent process has permission to switch to them.

This is commonly used when a parent starts with more privileges for some setup work, but the actual child doesn't need all that. An ordinary process can't just choose any user ID it wants, of course - the OS still checks whether the parent is allowed to do it.

And this is a POSIX feature. You don't get portable Windows user isolation by putting uid and gid into your launch helper, so don't try.

Also understand what dropping privileges actually does. It changes what the operating system lets that process access as that identity - that's it. Shell injection? Still there. Filenames? Not validated. Environment variables you inherited? Still inherited. File descriptors you already handed to the child? Still open. Those are all separate choices, and dropping uid solves exactly none of them.

Descriptors are easy to forget because they never appear in your argument list, but they're part of what the child receives also. If the child doesn't need stdin, don't inherit it just like that -

js
spawn(file, args, {
  stdio: ["ignore", "pipe", "pipe"],
  env,
  cwd,
});

Now stdin is ignored, while stdout and stderr are still there to read. Six months later, this is much easier to understand than "we inherited everything from the parent and hoped the child would behave itself". Hope is not a stdio policy.

How I review process-launch code

When I'm checking a child-process call, I usually don't start with the spawn() itself. I read the values going into it first, because the values are where the trouble lives.

What executable is actually getting launched? Fixed path, or are we relying on PATH lookup and praying?

Then args - are the outside values complete array entries, or did somebody build a command string somewhere upstream?

Then cwd - fixed? validated? does some relative executable silently depend on it?

Then env - did we choose what the child sees with any thought, or did we copy every variable from the parent because that was easier? It's always because it was easier.

Then stdio - are stdout and stderr actually being read? Is stdin inherited even though nobody needs it? Any extra descriptors getting passed?

After that, limits. Buffered API? I want to know the timeout and maxBuffer. Streaming API? I want to know who is draining the output and whether any byte limit exists at all.

Windows code gets one more look, because I want to know if we're launching a real .exe or sending something through cmd.exe.

And shell: true should be very visible when reading the code. Once a shell is involved, the command string is shell code, full stop, and outside values have to be handled with that fact in mind.

One more Node v24 thing - using an args array together with shell: true runs into DEP0190, so don't build new launch helpers around that pattern.

Most safe process launching comes down to being annoyingly explicit. Pick the executable you actually want. Keep variable values in argv when they're arguments. Fix the working directory. Build the environment the child needs instead of forwarding your own. Decide upfront what happens to stdin, stdout and stderr. Put limits around runtime and output. And if Windows or a shell changes how the command gets parsed, make that visible in the code itself.

Boring launch code is good launch code.

You'll appreciate the boring version the first time this runs under systemd, inside a container, on somebody else's Windows machine, with a completely different PATH - and somehow still behaves exactly the same. That's the whole reward, right there.