Every permission bug looks the same in hindsight. Some function deep in the call stack assumes the caller already checked isAdmin. It didn’t. Or a new role gets added, and you grep for role === 'editor' across 47 files hoping you didn’t miss one.

Capability-based security fixes this by making authority explicit. Instead of asking “who are you?” and looking up permissions, you hand the caller a token that literally encodes what they are allowed to do. No token, no access. The type system can enforce this at compile time.

What capability-based security actually means

A capability is an unforgeable token that grants the holder the right to perform a specific action. The term comes from operating systems research dating back to the 1960s, but the idea applies to application code just as well.

In a traditional role-based system, a user has a role, and you check that role at the point of action:

function deleteProject(user: User, projectId: string) {
  if (user.role !== 'admin') {
    throw new UnauthorizedError();
  }
  // ... delete logic
}

This pattern is simple until it isn’t. The check lives far from where the authority was originally granted. You end up with redundant checks, forgotten checks, and logic that implicitly depends on checks made by callers ten frames up the stack.

Capabilities flip the model. The authority to delete a project becomes a value that you must possess to even call the function:

function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
  // No check needed. If you have the cap, you have the right.
  // ... delete logic
}

If you do not have a ProjectDeletionCapability, you cannot call this function. The type system says so.

Why TypeScript is a good fit

TypeScript’s structural typing is usually a feature, but for capabilities it is a bug. If ProjectDeletionCapability is just an interface with a projectId: string field, any object with that shape will pass. You need nominal typing. You need a type that cannot be constructed by accident.

The cleanest way to do this in TypeScript is a branded type using a private symbol:

declare const ProjectDeletionCapabilityBrand: unique symbol;

interface ProjectDeletionCapability {
  readonly [ProjectDeletionCapabilityBrand]: true;
  readonly projectId: string;
  readonly grantedAt: Date;
  readonly grantedBy: string;
}

Because ProjectDeletionCapabilityBrand is a unique symbol, no code outside this module can produce a value that satisfies the interface. The brand acts as a compile-time seal. You can construct the capability only inside the module that owns the symbol.

A concrete implementation

Here is a pattern that works in production code without turning your codebase into an abstract art project.

First, define a capability factory module. This is the only place that can mint new capabilities:

// capabilities.ts
import { randomUUID } from 'crypto';

declare const FileReadCapabilityBrand: unique symbol;
declare const FileWriteCapabilityBrand: unique symbol;

export interface FileReadCapability {
  readonly [FileReadCapabilityBrand]: true;
  readonly fileId: string;
  readonly scope: 'public' | 'private';
}

export interface FileWriteCapability {
  readonly [FileWriteCapabilityBrand]: true;
  readonly fileId: string;
}

// The capability factory. This is the only way to create capabilities.
export function mintFileReadCapability(
  fileId: string,
  scope: 'public' | 'private'
): FileReadCapability {
  return { [FileReadCapabilityBrand]: true, fileId, scope } as FileReadCapability;
}

export function mintFileWriteCapability(fileId: string): FileWriteCapability {
  return { [FileWriteCapabilityBrand]: true, fileId } as FileWriteCapability;
}

Your authorization layer, whatever it is, mints capabilities after verifying the user’s claims:

// auth.ts
import { mintFileReadCapability, mintFileWriteCapability } from './capabilities';

export async function getCapabilitiesForUser(userId: string, fileId: string) {
  const perms = await db.permissions.find({ userId, fileId });
  const caps = [];

  if (perms.canRead) {
    caps.push(mintFileReadCapability(fileId, perms.scope));
  }
  if (perms.canWrite) {
    caps.push(mintFileWriteCapability(fileId));
  }

  return caps;
}

Your domain functions consume capabilities directly. No user IDs, no role checks, no database lookups:

// files.ts
import { FileReadCapability, FileWriteCapability } from './capabilities';

export async function readFile(cap: FileReadCapability): Promise<Buffer> {
  return storage.read(cap.fileId);
}

export async function writeFile(
  cap: FileWriteCapability,
  data: Buffer
): Promise<void> {
  return storage.write(cap.fileId, data);
}

If you try to pass a FileReadCapability to writeFile, TypeScript will refuse to compile. The error is immediate and local. You do not need to trace through a role hierarchy to understand whether a call is valid.

Composing capabilities without losing safety

Real code needs to delegate. A service might hold several capabilities and pass subsets to helper functions. You can model this with intersection types:

function publishDocument(
  readCap: FileReadCapability,
  writeCap: FileWriteCapability,
  docId: string
) {
  const draft = await readFile(readCap);
  const rendered = renderToPDF(draft);
  await writeFile(writeCap, rendered);
  await markPublished(docId);
}

If you want to be stricter, you can define a composite capability:

interface FileReadWriteCapability
  extends FileReadCapability,
    FileWriteCapability {}

function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
  // ...
}

The key is that authority flows through values, not ambient state. You can trace every capability back to where it was minted.

Where this pattern breaks down

Capabilities are not free. Every protected operation needs a capability value passed through the call chain. In a deeply layered application, this can mean threading capabilities through five or six functions that do not use them directly.

There is also the revocation problem. A capability, once minted, is just a JavaScript object. It lives until garbage collected. If you need to revoke access immediately, say because a user was removed from a project, you cannot destroy capabilities already in flight. You need an out-of-band check, or you need to wrap capabilities in a proxy that validates against a live ACL on each use. That reintroduces the exact lookup you were trying to avoid.

Audit logging gets harder too. With RBAC, you log the user’s role at the time of action. With capabilities, the authority might have been granted hours ago by a different service. You need to attach metadata to the capability itself, which is why the examples above include grantedAt and grantedBy fields.

When to use capabilities and when to skip them

Use capabilities when you have fine-grained, contextual permissions that are expensive to recompute. A user can edit this specific document until Friday. A service can read from this bucket but not that one. The capability encodes the context. The function consuming it does not need to know the context exists.

Skip capabilities for coarse global roles. If your app has three permission levels and they never vary by resource, RBAC is simpler and easier to audit. Do not let the perfect be the enemy of the reasonably secure.

FAQ

What is the difference between a capability and a token?

A JWT or API token proves identity. A capability proves authority for a specific action. You can put capabilities inside a token, but the concepts are distinct.

Can capabilities work with GraphQL or REST?

Yes. The server mints capabilities after authenticating the request, then passes them into resolvers or controllers. The transport layer does not need to change.

How do you store capabilities?

Usually you do not persist capabilities. You persist the rules that determine whether a capability can be minted. The capabilities themselves are short-lived runtime values.

Does this replace OAuth scopes?

No. OAuth scopes are coarse-grained capabilities delegated across organizational boundaries. This pattern is for fine-grained authority inside your own application. They can coexist.

Can you serialize capabilities?

If you serialize a branded type, you lose the brand on deserialization. If you need to pass capabilities across processes, sign them with a key the receiver trusts, or use a capability server that validates and re-mints them.