Your processPayment function has access to the database, the payment gateway, and the audit log because it happens to live in the same process. If an attacker finds an injection bug in one HTTP handler, they inherit all of it. The function never asked for these powers. It simply assumed them by virtue of where it was deployed.
This is ambient authority, and it is the default model in almost every system we build. Capability-based security asks a different question: what if a function could only do what it was explicitly handed?
The ambient authority problem
Most applications use access control lists checked at the system boundary. A request hits an API gateway, JWT is validated, roles are checked, and then the request enters a trust zone where every function has implicit access to everything. The database connection is a global singleton. The S3 client is imported from a shared module. Any code that runs inside the process can invoke any of it.
This works until it doesn’t.
A deserialization bug in a utility function, a compromised dependency, or a confused deputy attack turns one small breach into full system access. The blast radius isn’t limited by what the specific operation needed. It is limited by what the entire process had.
Capability-based security flips the model. Authority is not a property of who you are. It is a property of what you hold.
What is capability-based security?
Capability-based security is a model where access rights are represented by unforgeable tokens, called capabilities, that must be explicitly passed to functions that need them. A capability is both a reference to a resource and the permission to use it. You cannot request access by name. You can only use a capability you already possess.
This is not role-based access control with extra steps. In RBAC, a user has a role, and the system checks that role at access time. In capability-based security, there is no central check. If you hold the capability, you can use it. If you don’t, you can’t. The capability itself is the proof of authorization.
This model dates back to operating system research in the 1970s, but it is increasingly relevant as we build more compartmentalized systems. WebAssembly modules, microservices, browser APIs, and sandboxed plugins all use capability-like patterns even if they don’t use the name.
How capabilities work in practice
Here is what this looks like in code. In the ambient authority model, any function can call the database:
// Ambient authority: any code in this module can use db
import { db } from './db';
async function getUser(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
async function processOrder(orderId: string) {
const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
// What if a bug here lets an attacker query arbitrary tables?
return order;
}
Both functions share a global db connection with full access. In the capability model, you pass a scoped capability:
// A capability is just a constrained handle
interface UserReadCap {
getUser(id: string): Promise<User>;
}
interface OrderReadCap {
getOrder(id: string): Promise<Order>;
}
async function getUser(id: string, db: UserReadCap) {
return db.getUser(id);
}
async function processOrder(orderId: string, orders: OrderReadCap) {
return orders.getOrder(orderId);
}
getUser cannot touch orders. processOrder cannot touch users. The compiler enforces this. A compromised processOrder implementation can only exfiltrate orders, not drop tables or read password hashes. The capability is the bound on behavior.
In languages with stronger type systems, you can push this further. In Rust, a capability can own a file descriptor. The type system ensures it cannot be duplicated without permission, and the borrow checker ensures it does not outlive its validity:
use std::fs::File;
use std::io::{self, Write};
fn write_log(file: &mut File, msg: &str) -> io::Result<()> {
// This function can ONLY write to the file it was handed.
// It cannot open new files. It cannot read the filesystem.
writeln!(file, "{}", msg)
}
The &mut File is the capability. The function cannot conjure another one.
Why capability-based security is not the default
Capability-based security has real costs. The most obvious is ergonomics. Every function signature grows. You have to thread capabilities through call chains, which feels like dependency injection taken to its logical extreme. In a large codebase, this can become tedious.
Error handling also gets more complex. In the ambient authority model, a failure to connect to the database is a global concern handled at initialization. In the capability model, every function that receives a capability must consider what happens if that capability is revoked or invalid mid-operation.
There is also a debugging cost. When access is denied in an ACL system, you check the policy. When a capability is missing, you trace back through the call chain to find who was supposed to pass it. This is a different skill set, and most developers are not used to it.
These trade-offs explain why capability-based security has been mostly confined to operating systems, browsers, and high-security environments. It is not free.
Where capability-based security shows up today
You have already used capability-based security, even if you didn’t know the name. In the browser, fetch is a global function, but it is constrained by the same-origin policy and CORS. A Service Worker receives specific event capabilities. A WebAssembly module must be explicitly granted access to memory and host functions.
In cloud infrastructure, AWS IAM policy conditions and scoped tokens are capability-like. A presigned S3 URL is a capability: an unforgeable token that grants a specific operation on a specific resource for a specific time. Kubernetes service accounts with narrowly scoped RBAC bindings move in this direction too.
The trend is toward smaller compartments with less ambient authority. Containers stripped it from the OS. WebAssembly stripped it from the browser process. The next step is stripping it from our own functions.
How to start using capabilities in your code
You do not need to rewrite your entire application. Start at the boundaries where the blast radius matters most.
Isolate your data access layer into capability objects. Instead of exporting a global database pool, export functions that return scoped handles:
// capabilities.ts
export interface UserStore {
getById(id: string): Promise<User | null>;
updateEmail(id: string, email: string): Promise<void>;
}
export interface AuditLog {
record(event: AuditEvent): Promise<void>;
}
// Hand out capabilities at the application boundary
function createUserStore(db: Pool): UserStore {
return {
async getById(id) {
const row = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return row ? mapUser(row) : null;
},
async updateEmail(id, email) {
await db.query('UPDATE users SET email = ? WHERE id = ?', [email, id]);
}
};
}
Then pass only the capabilities a handler needs:
async function handleProfileUpdate(
req: Request,
users: UserStore,
audit: AuditLog
) {
const user = await users.getById(req.userId);
await users.updateEmail(req.userId, req.body.email);
await audit.record({ type: 'email_changed', userId: req.userId });
}
handleProfileUpdate cannot send emails. It cannot delete accounts. It can only do what it was handed. If this handler has a deserialization bug, the attacker cannot pivot to the payment system because the capability was never passed.
Frequently asked questions about capability-based security
Isn’t this just dependency injection?
It looks similar, but the intent is different. Dependency injection is about testability and modularity. Capabilities are about security and least privilege. A DI container might still inject a global database connection. A capability is scoped to exactly what the caller should be allowed to do.
Does this replace authentication?
No. Authentication answers “who are you?” Capabilities answer “what can you do?” You still need to verify identity at the boundary. After that, capabilities constrain what the authenticated code can touch.
What languages support this well?
Any language with a type system can express capabilities as interfaces or traits. Rust and TypeScript both work well. In dynamically typed languages like Python or JavaScript without strict interfaces, you lose compile-time enforcement, but the pattern still improves code clarity and limits accidental misuse.
Ambient authority is a habit, not a law. Capability-based security is harder to adopt than to understand, but the direction of the industry is clear. Smaller boundaries. Less implicit power. Functions that can only do what they were explicitly handed.
Start with one handler. Pass it one capability instead of a database connection. See what breaks. Most of the time, the thing that breaks is a hidden assumption you didn’t know your code was making.