Introduction
Every Node.js script has unrestricted access to your filesystem, network, environment variables, and system processes. Run npm install and a postinstall script can read your SSH keys, exfiltrate environment variables, or install malware—and you'd never know until it's too late. Deno flips this model on its head: by default, a Deno script can do nothing. No file access, no network, no environment variables, no subprocess execution. Every capability must be explicitly granted through permission flags.
This security-first design isn't theoretical—it's a practical response to real-world supply chain attacks. In 2021, the ua-parser-js package (downloaded 7 million times per week) was compromised to include cryptocurrency miners and credential stealers. In 2022, node-ipc was sabotaged to overwrite files on systems with Russian or Belarusian IP addresses. These attacks exploited the fundamental problem with Node.js's security model: there isn't one.
Deno's permission system provides granular, auditable access control that prevents these attacks by default. This guide explores how the permission model works, how to configure it for production applications, and how it compares to alternative security approaches.
Understanding Deno Security: Core Concepts
The Default-Deny Principle
When you run a Deno script without any permission flags, the script operates in a sandboxed environment:
// This script will crash immediately
const data = await Deno.readTextFile("./config.json");
// Error: Requires read access to "./config.json", run again with the --allow-read flag// This will also crash
const response = await fetch("https://api.example.com");
// Error: Requires net access to "api.example.com", run again with the --allow-net flag// Environment variables? Also blocked.
console.log(Deno.env.get("HOME"));
// Error: Requires env access to "HOME", run again with the --allow-env flagEvery capability—file system access, network access, environment variables, subprocess execution, system information—requires explicit permission. This is the principle of least privilege applied to runtime environments.
Permission Categories
Deno defines these permission categories:
| Permission | Flag | Description |
|---|---|---|
| Read | --allow-read | File system read access |
| Write | --allow-write | File system write access |
| Net | --allow-net | Network access |
| Env | --allow-env | Environment variable access |
| Run | --allow-run | Subprocess execution |
| FFI | --allow-ffi | Foreign Function Interface |
| Sys | --allow-sys | System information (hostname, OS, etc.) |
| All | --allow-all | All permissions (escape hatch) |
Each permission can be granted globally or scoped to specific resources:
# Global: read access to everything
deno run --allow-read script.ts
# Scoped: read access to only ./data directory
deno run --allow-read=./data script.ts
# Multiple scoped permissions
deno run --allow-read=./data --allow-net=api.example.com:443 script.tsHow Permissions Are Checked
Deno's permission checks happen at the V8 engine level, not in JavaScript. When a script calls Deno.readTextFile(), the Rust-based runtime intercepts the call and checks permissions before executing the system call. This means:
- JavaScript can't bypass permissions: No amount of prototype pollution, eval, or dynamic code execution can circumvent the permission system.
- Permissions are checked on every call: Even if a script has read access to a file, each subsequent read is checked.
- Permissions can be revoked: A script can dynamically revoke its own permissions using
Deno.permissions.revoke().
Architecture and Design Patterns
Permission Request and Query
Deno provides a JavaScript API for managing permissions programmatically:
// Check current permission status
const readStatus = await Deno.permissions.query({ name: "read" });
console.log(readStatus.state); // "granted", "denied", or "prompt"
// Request permission (shows prompt to user in interactive mode)
const netStatus = await Deno.permissions.request({ name: "net", host: "api.example.com" });
if (netStatus.state === "granted") {
console.log("Network access granted");
}
// Revoke permission
await Deno.permissions.revoke({ name: "read" });
// Any subsequent Deno.readTextFile() calls will failScoped Permissions
Scoped permissions allow fine-grained control over which resources a script can access:
// This script was run with: deno run --allow-read=./config,./data script.ts
// Allowed
await Deno.readTextFile("./config/settings.json");
await Deno.readTextFile("./data/users.json");
// Denied - outside allowed scope
await Deno.readTextFile("./secrets/api-keys.json");
// Error: Requires read access to "./secrets/api-keys.json"Network permissions support host and port scoping:
# Allow HTTPS to specific host
deno run --allow-net=api.example.com:443 script.ts
# Allow HTTP and HTTPS to multiple hosts
deno run --allow-net=api.example.com,cdn.example.com script.ts
# Allow any port on a specific host
deno run --allow-net=localhost script.tsPermission Inheritance
When Deno scripts import modules, the imported modules inherit the permissions of the main script. This means:
// main.ts (run with --allow-read=./data)
import { processData } from "./lib.ts";
// lib.ts has the same read permissions as main.ts
// If lib.ts tries to read outside ./data, it fails
await processData("./data/input.json"); // Works
await processData("./secrets/keys.json"); // FailsThis is a critical security property: third-party dependencies can't escalate their own permissions.
Dynamic Permission Management
Scripts can manage their own permissions at runtime:
// Start with no permissions
const readStatus = await Deno.permissions.query({ name: "read" });
console.log(readStatus.state); // "prompt"
// Request permission (interactive terminal will prompt user)
const newStatus = await Deno.permissions.request({ name: "read" });
console.log(newStatus.state); // "granted" if user approved
// Use the permission
const config = await Deno.readTextFile("./config.json");
// Revoke permission when done
await Deno.permissions.revoke({ name: "read" });
// Any subsequent read attempts will failStep-by-Step Implementation
Running Scripts with Minimal Permissions
# A simple HTTP server
deno run --allow-net=:8000 server.ts
# A file processor
deno run --allow-read=./input --allow-write=./output processor.ts
# A database migration tool
deno run --allow-read=./migrations --allow-net=db.example.com:5432 migrate.ts
# A CLI tool that reads config and makes API calls
deno run --allow-read=./config.json --allow-net=api.example.com --allow-env=API_KEY cli.tsBuilding a Secure Configuration Loader
// config.ts
// Run with: deno run --allow-read=./config.json --allow-env=APP_ config.ts
interface Config {
port: number;
database: {
host: string;
port: number;
name: string;
};
apiKey: string;
}
async function loadConfig(): Promise<Config> {
// Check permissions before attempting access
const readPerm = await Deno.permissions.query({ name: "read" });
if (readPerm.state !== "granted") {
throw new Error("Read permission required for config file");
}
// Load config file
let fileConfig: Partial<Config> = {};
try {
const content = await Deno.readTextFile("./config.json");
fileConfig = JSON.parse(content);
} catch {
// Config file is optional
}
// Environment variables override file config
const envPerm = await Deno.permissions.query({ name: "env" });
if (envPerm.state === "granted") {
return {
port: parseInt(Deno.env.get("APP_PORT") ?? String(fileConfig.port ?? 3000)),
database: {
host: Deno.env.get("APP_DB_HOST") ?? fileConfig.database?.host ?? "localhost",
port: parseInt(Deno.env.get("APP_DB_PORT") ?? String(fileConfig.database?.port ?? 5432)),
name: Deno.env.get("APP_DB_NAME") ?? fileConfig.database?.name ?? "app",
},
apiKey: Deno.env.get("APP_API_KEY") ?? fileConfig.apiKey ?? "",
};
}
return fileConfig as Config;
}Implementing Permission-Aware Libraries
// lib/fs-utils.ts
// A library that gracefully handles missing permissions
export async function safeReadFile(path: string): Promise<string | null> {
const perm = await Deno.permissions.query({ name: "read", path });
if (perm.state !== "granted") {
console.warn(`Read permission not granted for ${path}`);
return null;
}
try {
return await Deno.readTextFile(path);
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
return null;
}
throw err;
}
}
export async function safeWriteFile(path: string, content: string): Promise<boolean> {
const perm = await Deno.permissions.query({ name: "write", path });
if (perm.state !== "granted") {
console.warn(`Write permission not granted for ${path}`);
return false;
}
await Deno.writeTextFile(path, content);
return true;
}Secure Dependency Management
// deps.ts - Centralized dependency management
// Import all dependencies from a single file for security auditing
export { serve } from "https://deno.land/std@0.224.0/http/server.ts";
export { parse } from "https://deno.land/std@0.224.0/flags/mod.ts";
export { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
// Pin versions to prevent supply chain attacks
// Never use @latest or unversioned URLs in productionReal-World Use Cases
Use Case 1: Running Untrusted Code
// sandbox.ts - Execute untrusted user code safely
async function runUserCode(code: string): Promise<string> {
// Write user code to a temporary file
const tempFile = await Deno.makeTempFile({ suffix: ".ts" });
await Deno.writeTextFile(tempFile, code);
try {
// Run with NO permissions - pure computation only
const command = new Deno.Command("deno", {
args: ["run", "--no-prompt", tempFile],
stdout: "piped",
stderr: "piped",
});
const process = await command.output();
if (process.success) {
return new TextDecoder().decode(process.stdout);
} else {
return `Error: ${new TextDecoder().decode(process.stderr)}`;
}
} finally {
await Deno.remove(tempFile);
}
}
// User code can't access filesystem, network, or env
const result = await runUserCode(`
function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(10));
`);
console.log(result); // "55"Use Case 2: CI/CD Pipeline Security
# .github/workflows/deno.yml
name: Deno CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
# Run tests with minimal permissions
- name: Run tests
run: deno test --allow-read=./src,./tests --allow-net=localhost
# Type check with no permissions needed
- name: Type check
run: deno check src/**/*.ts
# Security audit
- name: Check permissions
run: |
echo "Checking for --allow-all flags..."
! grep -r "allow-all\|allowAll\|-A " scripts/ || exit 1Use Case 3: Multi-Tenant SaaS Isolation
// tenant-sandbox.ts
interface TenantConfig {
id: string;
allowedPaths: string[];
allowedHosts: string[];
}
async function executeForTenant(
tenant: TenantConfig,
script: string
): Promise<{ success: boolean; output: string }> {
// Build permission flags based on tenant configuration
const permissionFlags = [
...tenant.allowedPaths.map((p) => `--allow-read=./tenants/${tenant.id}/${p}`),
...tenant.allowedHosts.map((h) => `--allow-net=${h}`),
];
const command = new Deno.Command("deno", {
args: ["run", ...permissionFlags, "--no-prompt", "-"],
stdin: "piped",
stdout: "piped",
stderr: "piped",
});
const process = command.spawn();
// Write script to stdin
const writer = process.stdin.getWriter();
await writer.write(new TextEncoder().encode(script));
await writer.close();
const output = await process.output();
return {
success: output.success,
output: new TextDecoder().decode(output.stdout),
};
}Best Practices for Production
-
Never use
--allow-allin production: This defeats the entire purpose of the permission system. If you need multiple permissions, specify them individually. -
Use scoped permissions: Instead of
--allow-read, use--allow-read=./data,./configto limit filesystem access to specific directories. -
Audit dependency permissions: Before adding a new dependency, check what permissions it might need. Run your test suite with the minimum required permissions to catch unexpected access patterns.
-
Use
deno.jsonpermission presets: Define named permission sets indeno.jsontasks for different operations (dev, test, deploy). -
Implement permission checks in libraries: If your library needs specific permissions, check them at runtime and provide clear error messages when they're missing.
-
Use
--no-promptin CI/CD: Prevent Deno from prompting for permissions in automated environments. All permissions should be explicitly granted via flags. -
Rotate and limit API keys: Even with Deno's permission system, compromised API keys can be exploited. Use short-lived tokens and rotate them regularly.
-
Monitor permission usage: Log when your application encounters permission denials. This can indicate misconfiguration or attempted attacks.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Using --allow-all for convenience | Complete security bypass | Specify exact permissions; use deno.json tasks |
| Forgetting permissions in production | Runtime crashes | Test with production permissions in CI |
| Assuming dependencies are safe | Supply chain attacks | Audit dependencies; pin versions; test with minimal perms |
| Not handling permission denial | Unhandled errors | Check permissions with Deno.permissions.query() before access |
| Over-scoping network permissions | Data exfiltration risk | Use --allow-net=specific-host:port |
Ignoring --allow-run risks | Arbitrary code execution | Never grant --allow-run to untrusted code |
Performance Optimization
// Permission checking has negligible overhead
// The Rust-based permission check takes ~100 nanoseconds per call
// Cache permission check results for hot paths
let readPermGranted: boolean | null = null;
async function cachedReadFile(path: string): Promise<string> {
if (readPermGranted === null) {
const status = await Deno.permissions.query({ name: "read" });
readPermGranted = status.state === "granted";
}
if (!readPermGranted) {
throw new Error("Read permission not granted");
}
return await Deno.readTextFile(path);
}// Use --cached-only flag for offline/secure environments
deno run --cached-only --allow-read=./data script.ts
// This prevents Deno from downloading any new modules
// Only modules already in DENO_DIR will be usedComparison with Alternatives
| Feature | Deno Permissions | Node.js | Browser Sandbox | Docker |
|---|---|---|---|---|
| Default Access | None | Full | Limited | Limited |
| Granularity | File, host, env | None | Origin-based | Container-level |
| Runtime Enforcement | V8/Rust | None | Browser | Kernel |
| Performance Overhead | ~100ns/check | N/A | N/A | Higher |
| Bypass Risk | Very low | N/A | Medium | Low |
| Ease of Use | CLI flags | None | Automatic | Dockerfile |
| Supply Chain Protection | Strong | None | Medium | Medium |
Advanced Patterns
Custom Permission Handlers
// Create a permission-aware wrapper for common operations
class PermissionGuard {
private cache = new Map<string, boolean>();
async check(permission: Deno.PermissionDescriptor): Promise<boolean> {
const key = JSON.stringify(permission);
if (this.cache.has(key)) return this.cache.get(key)!;
const status = await Deno.permissions.query(permission);
const granted = status.state === "granted";
this.cache.set(key, granted);
return granted;
}
async requireRead(path: string): Promise<void> {
if (!(await this.check({ name: "read", path }))) {
throw new Error(`Read permission required for: ${path}`);
}
}
async requireNet(host: string): Promise<void> {
if (!(await this.check({ name: "net", host }))) {
throw new Error(`Network permission required for: ${host}`);
}
}
async requireEnv(key: string): Promise<void> {
if (!(await this.check({ name: "env", variable: key }))) {
throw new Error(`Environment permission required for: ${key}`);
}
}
}
const guard = new PermissionGuard();
// Use in application code
await guard.requireRead("./data/users.json");
const users = await Deno.readTextFile("./data/users.json");Permission-Based Feature Flags
// Feature detection based on available permissions
async function getCapabilities(): Promise<string[]> {
const capabilities: string[] = [];
const read = await Deno.permissions.query({ name: "read" });
if (read.state === "granted") capabilities.push("file-read");
const write = await Deno.permissions.query({ name: "write" });
if (write.state === "granted") capabilities.push("file-write");
const net = await Deno.permissions.query({ name: "net" });
if (net.state === "granted") capabilities.push("network");
const env = await Deno.permissions.query({ name: "env" });
if (env.state === "granted") capabilities.push("environment");
return capabilities;
}
// Adapt behavior based on available capabilities
const caps = await getCapabilities();
if (caps.includes("network")) {
await fetchRemoteConfig();
} else {
console.log("Running in offline mode (no network permission)");
}Secure Subprocess Execution
// Execute subprocesses with restricted permissions
async function secureExec(
cmd: string[],
options: { allowedPaths?: string[]; timeout?: number } = {}
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const command = new Deno.Command(cmd[0], {
args: cmd.slice(1),
stdout: "piped",
stderr: "piped",
});
// Set timeout to prevent hanging
const timeout = options.timeout ?? 30_000;
try {
const output = await Promise.race([
command.output(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Command timed out")), timeout)
),
]);
return {
success: output.success,
stdout: new TextDecoder().decode(output.stdout),
stderr: new TextDecoder().decode(output.stderr),
};
} catch (err) {
return {
success: false,
stdout: "",
stderr: err instanceof Error ? err.message : "Unknown error",
};
}
}Testing Strategies
// tests/permissions.test.ts
import { assertEquals, assertThrows } from "https://deno.land/std/assert/mod.ts";
Deno.test("permission query returns correct state", async () => {
// In test environment, permissions are granted via CLI flags
const readStatus = await Deno.permissions.query({ name: "read" });
assertEquals(readStatus.state, "granted");
});
Deno.test("scoped read permission works", async () => {
// Run with: deno test --allow-read=./tests
const content = await Deno.readTextFile("./tests/fixtures/test.txt");
assertEquals(typeof content, "string");
});
Deno.test("unscoped write permission denied", async () => {
// Run without --allow-write
const status = await Deno.permissions.query({ name: "write" });
// In a restricted environment, this would be "denied" or "prompt"
assertEquals(["granted", "denied", "prompt"].includes(status.state), true);
});
// Run with: deno test --allow-read=./testsFuture Outlook
The Deno team is working on:
- Permission profiles: Named sets of permissions that can be shared across projects
- Conditional permissions: Grant permissions based on code signature or package integrity
- Audit logging: Automatic logging of all permission-gated operations
- Integration with npm: Permission hints in
package.jsonfor npm packages - WebAssembly sandboxing: Tighter integration with WASI for even stronger isolation
The goal is a security model that's both more granular and easier to use than any current alternative.
Conclusion
Deno's security model is its most distinctive and important feature. By requiring explicit permission for every system interaction, it prevents the supply chain attacks, data exfiltration, and unauthorized access that plague the Node.js ecosystem. The permission system adds negligible performance overhead while providing auditable, granular access control.
Key takeaways:
- Default-deny is the right security model — scripts should start with zero capabilities
- Scoped permissions limit blast radius —
--allow-read=./datais better than--allow-read - Dependencies inherit permissions — third-party code can't escalate beyond your grant
- Permission checks are virtually free — 100ns overhead is negligible
- Production should use minimal permissions — audit and restrict every flag
Deno proves that security and developer experience aren't mutually exclusive. The permission model is intuitive, performant, and provides real protection against the supply chain attacks that are becoming increasingly common in the JavaScript ecosystem.