Understanding Deno's Permission Model: From Zero Trust to Practical CI Workflows
Learn how Deno’s zero‑default permission model works, how to use flags and the Permissions API for gradual privilege granting, and how to lock down CI jobs without sacrificing safety.
24 Feb 2026, 15:31 UTC

The problem you hit first
You clone a Deno utility that talks to a GitHub API and needs an API_TOKEN from the environment. Running it with no flags yields:
deno run utils/fetch-release.ts
error: Uncaught PermissionDenied: network access to "https://api.github.com", run again with the --allow-net flag
The script stops before it even tries to read the environment variable. This is Deno’s zero‑trust baseline: every capability is denied unless you explicitly allow it.
Thesis: fine‑grained grants enable progressive disclosure
Deno’s permission system is designed so you can start with the smallest possible set of privileges and expand only when the code actually needs them. You can do this in two ways:
- Static flags passed to
deno run(ordeno test,deno cache, etc.) that lock down the whole process. - Runtime API
Deno.permissionsthat lets a script query or request a specific permission at the point of use.
Together they give you the ability to ship a library that fails fast when a required capability is missing, while still offering a smooth developer experience.
Section 1: Zero‑trust baseline and flag syntax
When Deno starts, the following ambient authorities are all set to denied:
- network (
--allow-net) - file system read (
--allow-read) - file system write (
--allow-write) - environment variables (
--allow-env) - subprocess spawning (
--allow-run) - FFI (
--allow-ffi) - high‑resolution time (
--allow-hrtime) - system information (
--allow-sys)
Each flag accepts a comma‑separated list of constraints. For example:
--allow-net=api.github.com,api.gitlab.comlimits outbound TCP/TLS to those hosts.--allow-read=/home/user/projectrestricts file reads to that directory tree.--allow-env=API_TOKEN,DEBUGallows only those variable names.
If you omit the constraint, the flag grants the capability universally (e.g., --allow-net allows any host).
Section 2: Progressive disclosure with Deno.permissions
Library authors often cannot know which exact hosts or paths a consumer will need. The runtime API lets the script ask for more rights only when it’s about to use them.
// utils/fetch-release.ts
import { assert } from "https://deno.land/std@0.224.0/testing/asserts.ts";
async function getToken(): Promise {
const envDesc = { name: "env", variable: "API_TOKEN" };
const state = await Deno.permissions.query(envDesc);
if (state.state === "granted") {
return Deno.env.get("API_TOKEN")!;
}
// Ask the user at runtime
const permission = await Deno.permissions.request(envDesc);
if (permission.state !== "granted") {
throw new Error("API_TOKEN environment variable access denied");
}
return Deno.env.get("API_TOKEN")!;
}
async function fetchLatest(tag: string) {
const netDesc = { name: "net", host: "api.github.com" };
const netState = await Deno.permissions.query(netDesc);
if (netState.state !== "granted") {
await Deno.permissions.request(netDesc);
}
const token = await getToken();
const resp = await fetch(`https://api.github.com/repos/denoland/deno/releases/tags/${tag}`, {
headers: { Authorization: `token ${token}` },
});
if (!resp.ok) throw new Error(`GitHub responded ${resp.status}`);
return await resp.json();
}
// Example usage
(async () => {
const release = await fetchLatest("v2.0.0");
console.log(`Latest release: ${release.name}`);
})();
When you run this script with only --allow-env=API_TOKEN, the first query for the network permission returns "denied", triggering a prompt (if running in a terminal) or throwing if the permission cannot be interacted with. After you grant network access to api.github.com, the request proceeds.
Section 3: Friction points and CI considerations
Despite the elegant model, a few practical issues appear:
- No declarative manifest: Unlike
package.json’sscriptsordependencies, Deno modules cannot advertise required permissions. Consumers must read documentation or catchPermissionDeniederrors at runtime. - CI/CD temptation to use
--allow-all: For quick pipeline setup, many teams add the blanket flag, which nullifies the security model. Deno 1.40+ introduced--deny-*flags to explicitly block dangerous capabilities even when--allow-allis present (e.g.,deno run --allow-all --deny-write script.ts). - Deploy sandbox differences: Deno Deploy and Deno Subhosting disallow
--allow-run,--allow-ffi, and restrict network to a limited set of outbound hosts. Code that works locally may fail in production if it relies on those capabilities. - One‑way revocation: Calling
Deno.permissions.revoke({ name: "write" })permanently removes that grant for the lifetime of the process; there is no way to re‑grant without restarting. - Windows path quirks: On Windows,
--allow-read=C:\Users\meworks, but the POSIX‑style--allow-read=/c/Users/medoes not. Cross‑platform scripts need conditional flag logic or absolute paths viaDeno.makeTempDir.
Worked example: locking down a CI job
Suppose you have a CI pipeline that runs Deno tests for a library that only needs to read files from ./fixtures and make HTTP calls to https://registry.npmjs.org. You want to avoid the blanket --allow-all while still keeping the pipeline simple.
- Determine the minimal flag set:
deno test --allow-read=./fixtures --allow-net=registry.npmjs.org
- Add defensive denies in case a future change accidentally adds
--allow-all:
deno test --allow-all \
--deny-write \
--deny-env \
--deny-run \
--deny-ffi
The second command still grants read and network access (because --allow-all wins for those), but explicitly blocks write, environment, subprocess, and FFI access. If a test later tries to write a temporary file, it will fail with a clear PermissionDenied error, alerting you to the regression.
How to verify:
- Run the test suite with the first command; it should pass.
- Run the test suite with the second command but add an intentional
Deno.writeFilecall; you should see a permission error. - Remove the
--deny-writeflag; the write should succeed, confirming the deny flag is active.
Trade‑off and limitation
The biggest trade‑off is usability versus security. Using --allow-all is tempting for local experimentation and CI bootstrapping, but it opens the door to supply‑chain attacks if a compromised dependency tries to read ~/.ssh/id_rsa or spawn a shell. The one‑way nature of Deno.permissions.revoke means you cannot implement a hot‑reload workflow that temporarily drops a privilege and then restores it without restarting the process.
Practical mitigation:
- Pin the Deno version in CI (
deno install -v 1.42.0) because permission flags can shift between minor releases. - Document the exact permission set your module needs in its
README.md(e.g., "Requires--allow-net=api.github.comand--allow-env=GITHUB_TOKEN"). - Use
deno info --json mod.tsto confirm that no permission metadata is present (highlighting the gap) and treat the absence as a signal to consult documentation.
Actionable closing
Start every new Deno project with the principle of least privilege:
- Run your script or tests with no flags; note the first
PermissionDeniederror. - Add the narrowest flag that satisfies that error (host‑specific
--allow-net, path‑specific--allow-read, etc.). - Repeat until the script runs successfully.
- In CI, replace any
--allow-allwith the minimal flag set plus explicit--deny-*for high‑risk categories you do not need. - Document the final flag set in your project’s contribution guide so future contributors know the security baseline.
By treating permissions as a first‑class concern rather than an afterthought, you keep the supply‑chain surface small while still enjoying Deno’s modern standard library and tooling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.