Configuring Deno Permissions for Least-Privilege Script Execution
A practical guide to configuring Deno's permission flags for least-privilege execution: mapping requirements, applying granular --allow-* and --deny-* flags, handling dynamic requests, and verifying the effective sandbox with static analysis and runtime smoke tests.
12 Jul 2026, 17:57 UTC

The Problem: Default Deny Requires Explicit Allow
Deno executes every script in a secure sandbox by default. No file system, network, environment, or subprocess access is granted unless you explicitly opt in via command-line flags. This design eliminates entire classes of supply-chain and privilege-escalation bugs, but it shifts the burden to you: you must declare exactly what each script needs, and no more.
The useful takeaway: treat permission flags as part of your deployment contract. A script that runs with --allow-read=/data --allow-net=api.example.com:443 documents its own attack surface. If the flag list grows, that's a signal to refactor.
Prerequisites
- Deno 1.30 or later (required for
--deny-*flags). Verify withdeno --version. - A script or entry point you control—TypeScript or JavaScript, local or fetched via HTTPS.
- Terminal access to run
deno runcommands. No elevated privileges needed; the sandbox is enforced by the runtime itself.
Step 1: Map the Script's Actual Requirements
Before adding flags, list every external resource the code touches:
- File reads: configuration files, certificates, data directories.
- File writes: logs, caches, output artifacts.
- Outbound network: API endpoints, databases, CDN origins.
- Inbound listeners: HTTP servers, WebSocket ports.
- Environment variables: secrets, feature flags, runtime config.
- Subprocesses: CLI tools, compilers, database migrations.
- High-resolution time or FFI: profiling, native interop.
If a third-party module is imported via HTTPS, its permissions are evaluated in the same process. You cannot sandbox an imported module separately without spawning a worker or a separate process.
Step 2: Apply Granular --allow-* Flags
Each flag accepts an optional comma-separated list of paths or host:port pairs. Omitting the list grants unrestricted access for that capability—avoid this in production.
deno run \
--allow-read=/etc/app/config.yaml,/var/data \
--allow-write=/var/log/app,/tmp/app-cache \
--allow-net=api.payments.example:443,db.internal:5432 \
--allow-env=DATABASE_URL,API_KEY \
--allow-run=pg_dump,pg_restore \
main.ts
Where to run: Any shell with Deno on PATH. Permissions: Your user account only; the flags constrain the Deno process, not the shell. Placeholders: Replace paths and hosts with your actual values. Expected check: The script starts without PermissionDenied errors for the listed resources. Risk: A typo in a path (e.g., /var/log/app/ vs /var/log/app) silently denies access; test each path.
Step 3: Add Defense-in-Depth with --deny-* Flags (Deno 1.30+)
--deny-* flags carve out exceptions inside an allowed tree. They are evaluated after --allow-*, so a deny always wins.
deno run \
--allow-read=/home/app \
--deny-read=/home/app/.ssh,/home/app/secrets \
main.ts
This pattern is useful when a framework or build tool expects broad read access but you must protect SSH keys or credential files. Note: --deny-net and --deny-env work analogously.
Step 4: Handle Dynamic Privilege Escalation
For interactive CLIs or installers, request additional permissions at runtime:
// prompt_user.ts
const status = await Deno.permissions.request({ name: "read", path: "/tmp/user-upload" });
if (status.state === "granted") {
const data = await Deno.readTextFile("/tmp/user-upload");
console.log("Processing:", data.slice(0, 100));
} else {
console.error("Read access denied by user.");
Deno.exit(1);
}
Run with deno run --allow-read=/tmp prompt_user.ts. The initial flag grants /tmp; the runtime prompt asks for the specific sub-path. In CI, add --no-prompt to auto-deny ungranted requests, causing the script to fail fast.
Step 5: Verify the Effective Permission Set
Deno 1.25+ can statically analyze a script's permission requirements:
deno info --json main.ts | jq '.permissions'
Output example (abridged):
{
"read": ["/etc/app/config.yaml", "/var/data"],
"write": ["/var/log/app", "/tmp/app-cache"],
"net": ["api.payments.example:443", "db.internal:5432"],
"env": ["DATABASE_URL", "API_KEY"],
"run": ["pg_dump", "pg_restore"]
}
Compare this list against your flag invocation. Missing entries indicate either dead code or a gap in your flags.
Step 6: Runtime Smoke Tests
Create minimal test scripts to confirm each boundary:
// test_read.ts
try {
await Deno.readTextFile("/var/data/allowed.txt");
console.log("OK: allowed read");
} catch (e) {
console.error("FAIL: allowed read denied", e);
}
try {
await Deno.readTextFile("/etc/passwd");
console.log("FAIL: blocked read succeeded");
} catch (e) {
if (e instanceof Deno.errors.PermissionDenied) {
console.log("OK: blocked read denied");
}
}
Run: deno run --allow-read=/var/data test_read.ts. Expect one "OK" and one "OK: blocked read denied". Repeat for net, env, and run with analogous tests.
Limitations and Gotchas
- Process-wide scope: A single
--allow-read=/datagrants read access to every module loaded in that process, including untrusted HTTPS imports. For mixed-trust code, spawn a worker withnew Worker(script, { type: "module", deno: { permissions: "inherit" } })and revoke specific permissions in the worker, or use a separatedeno runsubprocess. - Wildcards defeat granularity:
--allow-read=*is equivalent to--allow-read. Never use in production. - Network flags cover both directions:
--allow-net=api.example.com:443permits outbound HTTPS and inboundDeno.listen({ hostname: "api.example.com", port: 443 }). If you only need outbound, the flag still opens the listener path—bind to0.0.0.0or127.0.0.1intentionally. - Environment leakage:
--allow-envwithout a list exposes all variables. Always enumerate:--allow-env=VAR1,VAR2. --allow-runbypasses the sandbox: Spawningsh,bash, orpythonlets the subprocess do anything your user can do. If you must allow a specific CLI, pair with--deny-run=sh,bash(Deno 1.30+).- Flags are not persisted: They must be supplied at every invocation. Use a wrapper script,
deno.jsontasks, or your process manager (systemd, Docker entrypoint) to enforce consistency.
Recovery: When a Legitimate Access Is Blocked
If a script fails with PermissionDenied in production:
- Capture the exact error message—it includes the capability, the requested path/host, and the flag that would grant it.
- Verify the resource is genuinely required (not a buggy path construction).
- Add the minimal flag to the deployment wrapper, redeploy, and re-run the smoke test from Step 6.
- Update the static analysis baseline (
deno info --json) so future diffs catch regressions.
No rollback is needed for flag changes; they are ephemeral command-line arguments. The previous invocation simply lacks the new flag.
Quick Reference Card
| Capability | Allow Flag | Deny Flag (1.30+) | Example Scope |
|---|---|---|---|
| File read | --allow-read | --deny-read | /etc/app,/var/data |
| File write | --allow-write | --deny-write | /var/log/app,/tmp/cache |
| Network (out/in) | --allow-net | --deny-net | api.example.com:443,db:5432 |
| Environment | --allow-env | --deny-env | DATABASE_URL,API_KEY |
| Subprocess | --allow-run | --deny-run | pg_dump,pg_restore |
| High-res time | --allow-hrtime | — | (no scope) |
| FFI / syscalls | --allow-ffi, --allow-sys | — | (no scope) |
Keep this table in your runbook. When a new dependency appears, the required flag is immediately visible.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.