MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Deno Security Model: Permissions and Sandboxing

Understand Deno security: permission flags, granular access, and secure-by-default design.

DenoSecurityPermissionsRuntime

By MinhVo

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.

Security and sandboxing

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 flag

Every 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:

PermissionFlagDescription
Read--allow-readFile system read access
Write--allow-writeFile system write access
Net--allow-netNetwork access
Env--allow-envEnvironment variable access
Run--allow-runSubprocess execution
FFI--allow-ffiForeign Function Interface
Sys--allow-sysSystem information (hostname, OS, etc.)
All--allow-allAll 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.ts

How 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:

  1. JavaScript can't bypass permissions: No amount of prototype pollution, eval, or dynamic code execution can circumvent the permission system.
  2. Permissions are checked on every call: Even if a script has read access to a file, each subsequent read is checked.
  3. Permissions can be revoked: A script can dynamically revoke its own permissions using Deno.permissions.revoke().

Runtime security model

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 fail

Scoped 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.ts

Permission 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"); // Fails

This 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 fail

Step-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.ts

Building 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 production

Permission system architecture

Real-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 1

Use 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

  1. Never use --allow-all in production: This defeats the entire purpose of the permission system. If you need multiple permissions, specify them individually.

  2. Use scoped permissions: Instead of --allow-read, use --allow-read=./data,./config to limit filesystem access to specific directories.

  3. 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.

  4. Use deno.json permission presets: Define named permission sets in deno.json tasks for different operations (dev, test, deploy).

  5. Implement permission checks in libraries: If your library needs specific permissions, check them at runtime and provide clear error messages when they're missing.

  6. Use --no-prompt in CI/CD: Prevent Deno from prompting for permissions in automated environments. All permissions should be explicitly granted via flags.

  7. 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.

  8. Monitor permission usage: Log when your application encounters permission denials. This can indicate misconfiguration or attempted attacks.

Common Pitfalls and Solutions

PitfallImpactSolution
Using --allow-all for convenienceComplete security bypassSpecify exact permissions; use deno.json tasks
Forgetting permissions in productionRuntime crashesTest with production permissions in CI
Assuming dependencies are safeSupply chain attacksAudit dependencies; pin versions; test with minimal perms
Not handling permission denialUnhandled errorsCheck permissions with Deno.permissions.query() before access
Over-scoping network permissionsData exfiltration riskUse --allow-net=specific-host:port
Ignoring --allow-run risksArbitrary code executionNever 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 used

Comparison with Alternatives

FeatureDeno PermissionsNode.jsBrowser SandboxDocker
Default AccessNoneFullLimitedLimited
GranularityFile, host, envNoneOrigin-basedContainer-level
Runtime EnforcementV8/RustNoneBrowserKernel
Performance Overhead~100ns/checkN/AN/AHigher
Bypass RiskVery lowN/AMediumLow
Ease of UseCLI flagsNoneAutomaticDockerfile
Supply Chain ProtectionStrongNoneMediumMedium

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=./tests

Future 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.json for 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:

  1. Default-deny is the right security model — scripts should start with zero capabilities
  2. Scoped permissions limit blast radius — --allow-read=./data is better than --allow-read
  3. Dependencies inherit permissions — third-party code can't escalate beyond your grant
  4. Permission checks are virtually free — 100ns overhead is negligible
  5. 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.