Deno's Permission Model: Secure Scripts Without a Security Team
Deno scripts start with zero capabilities — no network, no files, no env vars. Here's how to use scoped permission flags to build least-privilege scripts that survive CI, plus where the model falls short.
17 Sept 2025, 14:17 UTC

The script that shouldn't have worked
Here's a scenario that plays out constantly in Node.js shops: someone downloads a utility script from a gist, runs it with node cleanup.js, and it works. It also had full access to the network, every file on disk, and all environment variables — including the cloud credentials sitting in ~/.aws. Node's security model is essentially "the script can do whatever the user can do," and most of us only think about that after something goes wrong.
Deno took a different position from day one: a running script starts with no capabilities. No network, no file system, no environment variables, no subprocess spawning. If the code tries to fetch a URL or read a file, it fails at runtime unless you explicitly granted that ability when you launched it. This post is about how to use that model well — and where it bites people in practice.
How the sandbox actually works
When you run deno run main.ts with no flags, the process is sandboxed. Capabilities are granted through flags on the command line:
--allow-net— outbound network access (optionally scoped to specific hosts, e.g.--allow-net=api.example.com)--allow-read/--allow-write— file system access, also scopable to paths like--allow-read=./data--allow-env— reading environment variables--allow-run— spawning subprocesses--allow-ffi— loading native libraries
The scoping is the part worth paying attention to. --allow-net=api.stripe.com means the process can talk to Stripe and nothing else. A compromised dependency can no longer quietly exfiltrate data to an arbitrary endpoint — the connection simply won't open. That's a meaningfully stronger guarantee than "we audited our dependencies once."
If you run a script without the flags and the code requests a capability, Deno (in an interactive terminal) prompts you to grant it for that process. In non-interactive environments like CI, there's no prompt — the call throws a PermissionDenied error instead. That distinction matters, and we'll come back to it.
A worked example: a log-shipping script
Say you have a small script that reads JSON log files from a local directory and POSTs them to an internal collector. A minimal, least-privilege invocation looks like this, run from the project directory as any user:
deno run \
--allow-read=./logs \
--allow-net=collector.internal:8080 \
ship.tsInside ship.ts, the code can use ordinary APIs — Deno.readTextFile, fetch — with no special imports. The permission check happens at the runtime boundary, not in your code:
for await (const entry of Deno.readDir("./logs")) {
if (!entry.name.endsWith(".json")) continue;
const body = await Deno.readTextFile(`./logs/${entry.name}`);
const res = await fetch("http://collector.internal:8080/ingest", {
method: "POST",
body,
});
if (!res.ok) throw new Error(`ingest failed: ${res.status}`);
}Now the verification step, which is the whole point: try to break it. Point the fetch at a different host, or change the read path to ../secrets. You should get a PermissionDenied error (or an interactive prompt, if you're in a terminal). If you don't get one, your flags are broader than you think — check for a stray -A (the grant-everything flag) in a task definition or wrapper script.
You can also query permissions programmatically. await Deno.permissions.query({ name: "net", host: "collector.internal:8080" }) returns the current state (granted, denied, or prompt), which is useful for failing fast with a clear message at startup instead of dying mid-run.
Where this model bites in practice
CI failures from missing flags. This is the most common pain point. A script works locally because you accepted a prompt weeks ago, then fails in CI with PermissionDenied because pipelines are non-interactive. The fix is cultural as much as technical: never rely on prompts. Declare the full flag set explicitly — in a deno.json task, a Makefile, or the CI job itself — so the permission contract travels with the code. For example:
{
"tasks": {
"ship": "deno run --allow-read=./logs --allow-net=collector.internal:8080 ship.ts"
}
}Then everyone runs deno task ship and the permissions are reviewable in version control, right next to the code they protect.
Permissions are per-process, not persistent. Be aware that grants live for the lifetime of the process (or until revoked via the permissions API). There's no durable system-level store silently carrying your approvals between runs — each launch starts from the flags you pass. That's good for predictability, but it means wrapper scripts and deno.json tasks are the real unit of policy.
It's a boundary, not a vault. The sandbox governs what the process can do. It won't save you from a malicious dependency that operates entirely within the permissions you granted — if you allow network access to a host, a bad package can send whatever it can read to that host. Scoped flags shrink the blast radius; they don't eliminate it. You still need dependency review and lockfiles (deno.lock with --frozen in CI).
The takeaway
Deno's permission model turns "what can this script touch?" from an audit question into a command-line answer. The practical playbook is short: scope every flag to the narrowest host or path that works, encode the flags in deno.json tasks so CI and teammates run the same contract, and periodically test the boundary by intentionally exceeding it. Ten minutes of that discipline gets you a security posture that, in the Node world, typically requires containers or seccomp profiles to approximate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.