Skip to main content
workerd implements a capability-based security model where workers can only access resources explicitly granted to them through bindings. This approach provides strong security guarantees and makes applications more composable.

What are capabilities?

A capability is an unforgeable reference to a resource that grants the holder permission to access that resource. In workerd, capabilities are expressed as bindings in the worker configuration. Traditional access control uses:
  • Global namespaces: Access resources by name (URLs, file paths, database names)
  • Ambient authority: Programs have implicit access to many resources
  • Separate authorization: Permission checks happen at access time
Capability-based security uses:
  • Explicit bindings: Resources must be explicitly granted
  • Least privilege: Workers start with zero access
  • Unforgeable references: Cannot fabricate access to resources

How capabilities work in workerd

Configuration defines capabilities

Capabilities are granted in the worker configuration:
const myWorker :Workerd.Worker = (
  serviceWorkerScript = embed "worker.js",
  compatibilityDate = "2024-01-01",
  
  # These bindings are capabilities
  bindings = [
    (name = "DATABASE", service = "postgres"),
    (name = "KV", kvNamespace = "my-kv"),
    (name = "API_KEY", text = "secret")
  ],
  
  # Global fetch capability
  globalOutbound = "internet"
);

Workers receive capabilities as bindings

The worker receives capabilities through the env parameter:
export default {
  async fetch(request, env, ctx) {
    // env.DATABASE is a capability to access the database service
    // env.KV is a capability to access the KV namespace
    // env.API_KEY is a capability (just a string in this case)
    
    const response = await env.DATABASE.fetch(request);
    return response;
  }
}
The worker cannot access any resource not present in env (except through fetch() if globalOutbound is configured).

Preventing SSRF attacks

Server-Side Request Forgery (SSRF) is a common vulnerability where an attacker tricks a server into making requests to unintended destinations.

Traditional approach (vulnerable)

// VULNERABLE: Worker can fetch ANY URL
export default {
  async fetch(request) {
    const url = new URL(request.url).searchParams.get('url');
    
    // Attacker can pass internal URLs:
    // ?url=http://internal.company.com/admin
    // ?url=http://169.254.169.254/metadata  (cloud metadata)
    const response = await fetch(url);
    
    return response;
  }
}

Capability-based approach (secure)

// SECURE: Worker can only fetch from bound services
export default {
  async fetch(request, env) {
    const service = new URL(request.url).searchParams.get('service');
    
    // Only allowed services are accessible
    if (service === 'api') {
      return await env.API_SERVICE.fetch(request);
    } else if (service === 'data') {
      return await env.DATA_SERVICE.fetch(request);
    }
    
    return new Response('Service not found', { status: 404 });
  }
}
Configuration:
bindings = [
  (name = "API_SERVICE", service = "public-api"),
  (name = "DATA_SERVICE", service = "data-backend")
]
# No globalOutbound = worker cannot fetch arbitrary URLs

Controlled internet access

If a worker needs internet access, grant it explicitly with restrictions:
services = [
  (name = "internet", network = (
    allow = ["public"],  # Only public IPs
    deny = ["10.0.0.0/8", "192.168.0.0/16"],  # Block private ranges
    tlsOptions = (trustBrowserCas = true)
  ))
]

const myWorker :Workerd.Worker = (
  serviceWorkerScript = embed "worker.js",
  compatibilityDate = "2024-01-01",
  globalOutbound = "internet"  # Controlled access
);
The network service configuration defines exactly which addresses are reachable:
  • allow = ["public"]: Only publicly-routable IP addresses
  • deny = [...]: Explicitly block specific ranges
  • Private networks are blocked by default

Service isolation

Capabilities enable strong isolation between services.

Example: Multi-tenant architecture

services = [
  (name = "customer-a", worker = .customerWorker),
  (name = "customer-b", worker = .customerWorker),
  (name = "database-a", external = (
    address = "db-a.internal:5432",
    http = ()
  )),
  (name = "database-b", external = (
    address = "db-b.internal:5432",
    http = ()
  ))
]

const customerWorker :Workerd.Worker = (
  serviceWorkerScript = embed "customer.js",
  compatibilityDate = "2024-01-01",
  bindings = [(name = "DB", parameter = (type = (service = void)))]
);

const customerA :Workerd.Worker = (
  inherit = "customerWorker",
  bindings = [(name = "DB", service = "database-a")]
);

const customerB :Workerd.Worker = (
  inherit = "customerWorker",
  bindings = [(name = "DB", service = "database-b")]
);
The same worker code runs for both customers, but each has access only to their own database. There’s no way for customer A’s worker to access customer B’s database, even if the code is compromised.

Composability through capabilities

Capabilities make workers highly composable.

Parameter bindings

Define workers that require specific capabilities without specifying what they are:
const authWorker :Workerd.Worker = (
  serviceWorkerScript = embed "auth.js",
  compatibilityDate = "2024-01-01",
  bindings = [
    (name = "USER_DB", parameter = (type = (service = void))),
    (name = "SESSION_STORE", parameter = (type = (kvNamespace = void)))
  ]
);
Different deployments can fulfill these parameters differently:
# Development environment
const authDev :Workerd.Worker = (
  inherit = "authWorker",
  bindings = [
    (name = "USER_DB", service = "dev-user-db"),
    (name = "SESSION_STORE", kvNamespace = "dev-sessions")
  ]
);

# Production environment
const authProd :Workerd.Worker = (
  inherit = "authWorker",
  bindings = [
    (name = "USER_DB", service = "prod-user-db"),
    (name = "SESSION_STORE", kvNamespace = "prod-sessions")
  ]
);
The same code works in both environments with different backing resources.

Testing with mock services

Easily inject mock services for testing:
const mockDatabase :Workerd.Worker = (
  serviceWorkerScript = embed "mock-db.js",
  compatibilityDate = "2024-01-01"
);

const myWorkerTest :Workerd.Worker = (
  inherit = "myWorker",
  bindings = [
    (name = "DATABASE", service = "mock-database")
  ]
);
The test configuration replaces the real database with a mock, without changing the worker code.

Capability types

workerd supports various types of capabilities:

Service capabilities

Access to another worker or service:
(name = "BACKEND", service = "backend-service")

Storage capabilities

Access to persistent storage:
(name = "KV", kvNamespace = "namespace-id")
(name = "R2", r2Bucket = "bucket-service")
(name = "DO", durableObjectNamespace = "MyClass")

Network capabilities

Access to network resources:
globalOutbound = "internet"

Data capabilities

Configuration and secrets:
(name = "API_KEY", text = "secret")
(name = "CONFIG", json = "{\"key\": \"value\"}")
(name = "CERT", data = embed "cert.pem")

Cryptographic capabilities

Cryptographic keys:
(name = "SIGNING_KEY", cryptoKey = (
  raw = embed "key.bin",
  algorithm = (name = "HMAC"),
  extractable = false,
  usages = ["sign", "verify"]
))
With extractable = false, the worker can use the key but cannot extract the raw key material - the key never leaves the runtime in plaintext.

Security properties

The capability model provides strong security properties:

Least privilege by default

Workers start with zero access. They can only access resources explicitly granted:
export default {
  async fetch(request, env) {
    // This works: env.ALLOWED_SERVICE is bound
    await env.ALLOWED_SERVICE.fetch(request);
    
    // This fails: env.OTHER_SERVICE is not bound
    await env.OTHER_SERVICE.fetch(request);  // undefined
  }
}

Unforgeable references

Workers cannot create or guess valid capability references. They must receive them through bindings.

Audit trail

Configuration explicitly documents all resource access:
# Looking at this config, you can see exactly what the worker can access:
bindings = [
  (name = "USER_DB", service = "user-database"),
  (name = "CACHE", kvNamespace = "user-cache")
]
globalOutbound = "internet"

Delegation is explicit

For one service to give another service access to a resource, it must explicitly pass the capability:
// worker-a.js
export default {
  async fetch(request, env) {
    // Explicitly passing the DB capability to worker B
    return await env.WORKER_B.fetch(request, {
      headers: { 'X-DB-Binding': JSON.stringify(env.DB) }
    });
  }
}
However, most commonly you’d configure delegation in the config file by binding the same resource to both workers.

Best practices

Grant minimal capabilities

Only grant the capabilities a worker actually needs:
# Bad: Worker can access everything
bindings = [
  (name = "ALL_SERVICES", service = "service-registry"),
  (name = "ROOT_DB", service = "root-database")
]

# Good: Worker can access only what it needs
bindings = [
  (name = "USER_API", service = "user-service"),
  (name = "USER_CACHE", kvNamespace = "users")
]

Use parameter bindings for reusable workers

Make workers generic by using parameter bindings:
const cacheWorker :Workerd.Worker = (
  serviceWorkerScript = embed "cache.js",
  compatibilityDate = "2024-01-01",
  bindings = [
    (name = "UPSTREAM", parameter = (type = (service = void))),
    (name = "CACHE", parameter = (type = (kvNamespace = void)))
  ]
);

Document capability requirements

Document what capabilities your worker expects:
/**
 * Image processing worker
 * 
 * Required bindings:
 * - IMAGE_STORAGE: R2 bucket for storing processed images
 * - CACHE: KV namespace for caching thumbnails
 * - METRICS: Service for reporting processing metrics
 */
export default {
  async fetch(request, env) {
    // ...
  }
}

Test with restricted capabilities

Write tests that verify your worker handles missing capabilities gracefully:
export default {
  async fetch(request, env) {
    if (!env.OPTIONAL_FEATURE) {
      return new Response('Feature not available', { status: 503 });
    }
    return await env.OPTIONAL_FEATURE.fetch(request);
  }
}

Build docs developers (and LLMs) love