Grant Deno Scripts Only the Access They Need — Then Lock It In
Deno scripts start with no filesystem, network, or environment access. How to scope permission flags precisely, encode them in deno.json, and where the sandbox leaks.
28 Mar 2026, 10:03 UTC

You find a 30-line TypeScript script that pulls a CSV from an internal API and writes a summary file. On most runtimes, executing it hands it everything your user account can do: read your SSH keys, read every environment variable including tokens, reach any host on the network. Deno inverts that default. A script starts with nothing — no filesystem, no network, no environment — and each capability must be granted explicitly on the command line.
The useful takeaway: treat those flags as part of the script's interface. Scope them the way you'd scope an IAM policy, then record them in deno.json so the scoped version is the only version anyone runs.
Default-deny, flag by flag
Running deno run script.ts with no flags means any file, network, or environment access fails. The main capability flags are --allow-read, --allow-write, --allow-net, --allow-env, --allow-run (subprocesses), and --allow-sys (system information). Each one can be scoped:
--allow-read=./data— read access limited to that path; a bare--allow-readmeans the whole filesystem--allow-net=api.example.com:443— one host and port; without the port, any port on that host--allow-env=API_TOKEN— just that variable, not the entire environment
Two behavioral details matter. In an interactive terminal, Deno prompts you when a script touches something ungranted, so a forgotten flag becomes a question rather than a crash. In CI there is no one to answer, so pass --no-prompt and make missing grants fail immediately instead of stalling. Also note that relative paths in flags resolve against the directory you launch from, so --allow-read=./cache means different things from different working directories; prefer absolute paths in shared scripts.
Everything here assumes Deno 2.x. The permission system has worked this way since 1.0 in 2020, with one rename worth knowing: the permission error class moved from Deno.errors.PermissionDenied to Deno.errors.NotCapable in 2.0. Check what you are on with deno --version.
A worked example: fetch, count, cache
Here is a small script that needs exactly three capabilities: one host over HTTPS, and read/write on one cache directory.
// summarize.ts
const res = await fetch("https://api.example.com/usage.csv");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const csv = await res.text();
const rows = csv.trim().split("\n").length - 1; // minus header row
await Deno.writeTextFile("cache/row-count.txt", String(rows));
console.log(`Cached count: ${rows} rows`);Run it with nothing granted and the first network call fails with a permission error before the script touches the disk. Now grant only what it needs, from your shell:
deno run --no-prompt \
--allow-net=api.example.com:443 \
--allow-read=/var/tmp/summary-cache \
--allow-write=/var/tmp/summary-cache \
summarize.tsAdjust the cache path to a directory your user can write. With these flags the script can talk to exactly one host and read and write exactly one directory. If it later grows a call like Deno.readTextFile("~/.ssh/id_ed25519") — maliciously or by accident — that call throws instead of succeeding. Scripts run through Deno 2's npm compatibility get the same walls, which is a practical reason to reach for deno run npm:... when executing third-party tooling.
A quick negative test proves the flags are actually gating: rerun once with --allow-write removed. You should get a permission error on the write and no new file in the cache directory. That thirty-second check catches the classic mistake of tightening flags in one place while a stale alias or task still runs the broad version.
Where the walls have doors
Honest limitations, because "secure by default" oversells it:
- Subprocesses bypass everything. Code spawned through
Deno.Commandruns outside Deno's sandbox with your full OS privileges. A script granted--allow-run=shcan effectively do anything you can, so treat--allow-runas close to--allow-allin risk. - Grants are a ceiling, not a content filter. Anything a script loads at runtime —
eval, dynamically imported remote modules — inherits the parent's permissions. The flags constrain what the entry script's whole dependency graph can do, so a broad grant to a trusted-looking script covers everything it pulls in. - Coarse categories tempt broad grants. Typing
-A(allow everything) is easier than five scoped flags. That convenience is exactly the risk the model exists to prevent.
The trade-off is friction: someone has to know the flags. The fix is to make the scoped invocation the documented one.
Make the scoped version the only version
Encode the contract in deno.json:
{
"tasks": {
"summarize": "deno run --no-prompt --allow-net=api.example.com:443 --allow-read=/var/tmp/summary-cache --allow-write=/var/tmp/summary-cache summarize.ts"
}
}Now deno task summarize is the supported way to run the script: flags included, prompts off, nothing to remember or mistype. Three habits cover most of the value — scope every flag you grant, prefer absolute paths, and keep one negative test in your notes to confirm the walls are where you think they are.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.