Most APIs treat permissions as something that happens after the code has already decided what it wants to do.
You write:
deleteUser(userId)
Then somewhere inside the function:
if (!user.canDelete) {
throw ForbiddenError();
}
It works.
Until the system gets bigger.
Then permissions start appearing everywhere.
Controllers check roles.
Services check ownership.
Middleware checks tokens.
Database queries add tenant filters.
Background jobs bypass some checks.
Admin endpoints introduce special cases.
Eventually, permission logic becomes scattered across the architecture.
And that's when a dangerous idea emerges:
What if permissions weren't just runtime checks?
What if the API itself encoded permissions into its types?
Instead of thinking:
operation + permission check
we could think:
permission + operation = valid program
This changes the way we design APIs.
The compiler, type checker, or API boundary can become part of the authorization system.
Instead of merely asking:
"Does this user have permission?"
we can design systems where the software has enough information to make certain unauthorized operations difficult — or impossible — to express.
This is where API design starts touching type theory, capability-based security, Rust, domain-driven design, and distributed systems.
And I think it's one of the most interesting directions for backend architecture.
1. The Traditional Permission Model
Consider a simple API:
DELETE /users/42
The server receives the request.
It authenticates the caller.
Then it checks:
Is this user an administrator?
The architecture might look like:
Client
|
v
API
|
v
Authentication
|
v
Authorization
|
v
Business Logic
|
v
Database
This is normal.
But notice something.
The business operation exists independently from the permission.
We have:
deleteUser()
and separately:
canDeleteUser()
The programmer has to remember to connect them.
That creates a dangerous class of bugs:
deleteUser()
gets called from somewhere that forgot:
canDeleteUser()
Maybe a new endpoint.
Maybe a background job.
Maybe an internal service.
Maybe an administrative script.
The permission system becomes dependent on developer discipline.
And developer discipline doesn't scale very well.
2. Permissions Are Usually Metadata
Many systems represent permissions as strings:
"user.read"
"user.write"
"user.delete"
Then we might write:
if (user.permissions.includes("user.delete")) {
deleteUser(id);
}
This is convenient.
But strings have almost no compile-time meaning.
The compiler doesn't know that:
"user.delete"
is related to:
deleteUser()
You could accidentally write:
"user.deleet"
and the compiler might happily accept it.
You could also call:
deleteUser()
without checking the permission.
The relationship between authorization and behavior exists only in the programmer's head.
That's the problem.
3. What If Permission Became a Type?
Imagine we introduce:
struct ReadUser;
struct WriteUser;
struct DeleteUser;
Now we could define an operation requiring a specific capability:
fn delete_user(
permission: DeleteUser,
user_id: UserId,
) -> Result<(), Error> {
// ...
}
The interesting thing isn't the struct itself.
The interesting thing is:
Where does
DeleteUsercome from?
It should only be constructed if the authorization layer proves that the caller has the required permission.
Conceptually:
Authentication
|
v
Authorization
|
v
DeleteUser capability
|
v
delete_user()
Now the permission isn't merely a boolean.
It is a value with a type.
And the function cannot execute without it.
4. The Compiler Becomes Part of the Security Boundary
Consider:
fn delete_user(
capability: DeleteUser,
id: UserId,
) {
// delete
}
Some other code tries:
delete_user(UserId(42));
It doesn't compile.
The function requires:
DeleteUser
not:
UserId
The compiler is effectively saying:
You haven't demonstrated that you possess the capability required to perform this operation.
This is a profound shift.
Authorization is no longer only:
runtime decision
It becomes partially:
compile-time structure
Of course, authentication and authorization against a real user still happen at runtime.
The key is that the result of authorization can be represented as a strongly typed capability.
5. Capability-Based Thinking
This idea is closely related to capability-based security.
Instead of saying:
"This user has permission to delete users."
we give the program an object representing the authority:
DeleteUserCapability
Possession of that object means:
you are authorized to perform this operation
So:
User identity
|
v
Authorization policy
|
v
Capability
|
v
Operation
The capability becomes a token of authority.
This is different from passing around:
role = "admin"
because a role is broad.
A capability can be extremely specific.
For example:
DeleteUserCapability<User42>
or:
WriteDocumentCapability<Document17>
Now authorization can become much more granular.
6. Ownership Can Become Part of the Type
Imagine a multi-tenant application.
We have:
Tenant A
Tenant B
and documents:
Document 1 -> Tenant A
Document 2 -> Tenant B
A common security bug looks like:
GET /documents/2
where the current user belongs to Tenant A.
The application forgets to add:
WHERE tenant_id = current_tenant
Now Tenant A can see Tenant B's data.
Instead, imagine:
struct TenantId<T>;
struct Document<Tenant> {
id: DocumentId,
tenant: PhantomData<Tenant>,
}
The type system can begin carrying tenant context.
Conceptually:
Document<TenantA>
is not the same type as:
Document<TenantB>
Now we can design operations that require the correct tenant capability.
This is where type systems become interesting for security.
7. Permissioned APIs in TypeScript
TypeScript isn't as strict as Rust, but we can still model permissions.
For example:
type Permission =
| "user.read"
| "user.write"
| "user.delete";
Now we can define:
type Authorized<P extends Permission> = {
permission: P;
};
Then:
function deleteUser(
auth: Authorized<"user.delete">,
userId: string
) {
// ...
}
The caller needs a value proving that it has:
"user.delete"
We can then build an authorization function:
function authorize<P extends Permission>(
token: Token,
permission: P
): Authorized<P> | null {
// runtime authorization
}
Usage:
const capability =
authorize(token, "user.delete");
if (capability) {
deleteUser(capability, userId);
}
Now authorization has become part of the function's type contract.
8. Rust Makes This Even More Interesting
Rust's type system is particularly useful for this pattern.
Consider:
struct ReadUser;
struct WriteUser;
struct DeleteUser;
Then:
struct Capability<P> {
_permission: std::marker::PhantomData<P>,
}
Our API might expose:
fn read_user(
_: Capability<ReadUser>,
id: UserId,
) -> User {
// ...
}
and:
fn delete_user(
_: Capability<DeleteUser>,
id: UserId,
) -> Result<(), Error> {
// ...
}
Now these operations have explicit authority requirements.
The function signature tells the truth.
Reading requires:
Capability<ReadUser>
Deleting requires:
Capability<DeleteUser>
The permission isn't hidden inside the implementation.
It is part of the API.
9. This Changes API Documentation
Traditional API documentation might say:
DELETE /users/{id}
Requires admin role.
A capability-oriented API might conceptually describe:
delete_user(
DeleteUserCapability,
UserId
)
The required authority is visible in the interface itself.
This is powerful because the API becomes self-describing.
The type signature communicates:
what you can do
+
what authority you need
That's much harder to misunderstand.
10. Permissions Can Form a Type Hierarchy
Permissions aren't always independent.
Suppose:
Admin
implies:
ReadUser
WriteUser
DeleteUser
We could model capabilities hierarchically.
Conceptually:
AdminCapability
|
+----> ReadUser
+----> WriteUser
+----> DeleteUser
An administrator can derive narrower capabilities.
This is similar to how access tokens can be scoped.
Instead of giving every part of the application a giant:
Admin
object, we could derive:
DeleteUser
and pass only that capability to the component that needs it.
This follows a security principle that I really like:
Give code the minimum authority it needs.
11. Principle of Least Authority
Imagine a payment service.
It needs to charge customers.
It doesn't need:
deleteUser
readPayroll
modifyInventory
So instead of giving it:
Admin
give it:
ChargePayment
Now the architecture becomes:
Payment Service
|
v
ChargePaymentCapability
If the service is compromised, its authority is limited.
This is much safer than:
Payment Service
|
v
SuperAdmin
The type system helps communicate that boundary.
12. Capabilities Should Be Unforgeable
There is an important detail.
If anyone can simply write:
Capability::<DeleteUser>::new()
then the system is useless.
The constructor must be protected.
For example:
pub struct DeleteUser {
private: (),
}
Only the authorization subsystem can create it.
Conceptually:
External Code
|
X
Cannot construct capability
but:
Authorization Layer
|
v
Creates capability
This creates a controlled authority flow.
The capability becomes a proof.
13. Authorization as Proof
This is perhaps the deepest idea.
Instead of:
if authorized {
perform_operation();
}
we can think:
authorization()
|
v
proof
|
v
operation(proof)
The proof says:
The authorization system has established that this operation is allowed.
This resembles ideas from type theory and formal methods.
The type becomes a statement about what is valid.
For example:
DeleteUserCapability
can be interpreted as:
"This code possesses authority to perform user deletion."
The function doesn't need to repeatedly ask:
Are you allowed?
It already requires the proof.
14. But Runtime Authorization Still Exists
We shouldn't misunderstand this.
A type system cannot know:
which user is logged in
at compile time.
It cannot know:
whether Alice revoked Bob's access five seconds ago
It cannot know:
whether this JWT has expired
Those are runtime facts.
So the architecture becomes:
Runtime
|
| authenticate
v
Authorization
|
| establish capability
v
Type-safe business logic
The runtime layer establishes authority.
The type system ensures that authority is required where appropriate.
This is a powerful combination.
15. The API Boundary Is Still Untrusted
Suppose a client sends:
DELETE /users/42
The server cannot trust the client to provide:
DeleteUserCapability
The capability exists internally.
The external request is merely a request.
The server authenticates:
Who are you?
Then authorizes:
Are you allowed to delete user 42?
Then creates:
DeleteUserCapability
Then invokes:
delete_user(capability, 42)
The architecture is:
HTTP Request
|
v
Authentication
|
v
Authorization
|
v
Capability
|
v
Typed Business Logic
This is the important distinction between external permissions and internal authority.
16. Resource-Level Permissions
Global permissions aren't always enough.
Consider:
document.read
A user might have permission to read documents generally, but not every document.
We need:
document.read(document_id)
Now the capability could carry resource identity:
struct ReadDocument {
document_id: DocumentId,
}
Then:
fn read_document(
capability: ReadDocument
) -> Document {
// ...
}
The capability doesn't just prove:
You can read documents.
It proves:
You can read this specific document.
That is much stronger.
17. Ownership Can Be Encoded
Imagine:
struct Owned<T, Owner> {
value: T,
_owner: PhantomData<Owner>,
}
Conceptually:
Owned<Document, TenantA>
cannot be casually passed where:
Owned<Document, TenantB>
is required.
This allows the type system to encode relationships that would otherwise be represented only as runtime conditions.
It doesn't mean every application should implement a highly complex type-level security system.
But for critical systems, the idea is incredibly powerful.
18. Permissions and State Machines
Permissions can also depend on state.
Imagine an order:
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
Not every operation is valid in every state.
For example:
cancel()
may only be allowed when:
PENDING
We can model this with types:
struct PendingOrder;
struct PaidOrder;
struct ShippedOrder;
Then:
fn cancel(
order: Order<PendingOrder>,
capability: CancelOrder,
) -> CancelledOrder {
// ...
}
But:
fn cancel(
order: Order<ShippedOrder>,
capability: CancelOrder,
)
could be impossible to compile.
Now we have combined:
state
+
permissions
inside the type system.
This is extremely interesting.
The API doesn't just say:
"You need permission."
It says:
"You need permission and the resource must be in the correct state."
19. APIs Become State Machines
Traditional CRUD:
POST
GET
PATCH
DELETE
Type-driven API:
CreateOrder
|
v
PendingOrder
|
+----> PayOrder
|
v
PaidOrder
|
+----> ShipOrder
|
v
ShippedOrder
Each transition can require a capability.
PayOrderCapability
ShipOrderCapability
CancelOrderCapability
Now the API becomes a typed state machine.
This is much closer to the actual business domain.
20. Temporal Permissions
Permissions can also expire.
Imagine:
DeployToProduction
is granted for 30 minutes.
The type system cannot directly encode wall-clock time in an ordinary runtime application.
But the capability object can carry an expiration:
struct DeployCapability {
expires_at: Instant,
}
The operation checks:
if capability.expires_at < now() {
return Err(Error::ExpiredCapability);
}
Now we have:
type-level authority
+
runtime temporal validity
Again, the type says:
This is a deployment capability.
The runtime says:
This particular capability is still valid.
21. Permission Types Can Improve Service Boundaries
Imagine a large backend:
User Service
Order Service
Payment Service
Inventory Service
Instead of passing around:
UserContext
everywhere, we can pass narrower capabilities.
For example:
Order Service
|
+---- ReadCustomer
|
+---- CreateOrder
|
+---- ReadInventory
The Payment Service gets:
ChargePayment
but not:
DeleteCustomer
This makes service dependencies explicit.
You can inspect the function signatures and understand authority flow.
That's valuable for architecture reviews.
22. Permissions Become Architecture
This is the bigger idea.
Most organizations treat authorization as:
security layer
separate from:
architecture
But if permissions are part of the type system, authority becomes visible in the architecture itself.
For example:
fn process_payment(
charge: ChargePayment,
order: Order
)
immediately tells you:
This function has financial authority.
And:
fn read_customer(
capability: ReadCustomer,
customer_id: CustomerId
)
tells you:
This function can access customer data.
Security stops being invisible middleware.
It becomes part of the program's structure.
23. This Is Especially Interesting in Rust
Rust's ownership model makes this pattern particularly natural.
Rust already teaches developers:
Who owns this?
Who can mutate this?
How long can this reference live?
Adding capabilities extends the philosophy:
Who owns this?
Who can mutate this?
Who is authorized to perform this operation?
Imagine:
fn update_document(
capability: WriteDocument,
document: &mut Document,
) {
// ...
}
Now both memory authority and business authority appear in the signature.
That's a fascinating convergence.
The language isn't only checking memory safety.
It can help express authority boundaries.
24. Distributed Systems Make This Even More Interesting
Suppose Service A authorizes a user.
Then it calls Service B.
How does Service B know what authority the caller has?
We could propagate capability information.
But we should be careful.
We cannot simply serialize an internal capability object and trust it.
Instead, we might issue a signed authorization token:
{
"subject": "user-42",
"permissions": [
"order.read",
"order.create"
],
"expires_at": "..."
}
Service B validates the token.
Then internally creates:
OrderReadCapability
OrderCreateCapability
Now the architecture becomes:
Service A
|
| signed authorization
v
Service B
|
| validate
v
Typed capability
|
v
Business logic
This bridges distributed authorization with type-safe local authority.
25. The Capability Should Be Narrow
One of the biggest mistakes would be creating:
SuperAdminCapability
and passing it everywhere.
Then we've simply reinvented roles.
The goal is to create narrow capabilities.
Instead of:
Admin
use:
ReadUser
WriteUser
DeleteUser
CreateOrder
ChargePayment
RefundPayment
Then compose them only when necessary.
This gives us a more precise security model.
26. Designing the API Around Permissions
A useful architecture might look like:
HTTP Request
|
v
+----------------+
| Authentication |
+----------------+
|
v
+----------------+
| Authorization |
+----------------+
|
capability created
|
v
+----------------+
| Typed API |
+----------------+
|
v
+----------------+
| Domain Logic |
+----------------+
|
v
+----------------+
| Database |
+----------------+
The key transition is:
authorization result
|
v
typed authority
That is where the architecture changes.
27. What Happens When Permissions Change?
Suppose Alice has:
WriteDocument
and then an administrator revokes her access.
An existing capability might still exist in memory.
This means capabilities should have carefully defined lifetimes.
For short-lived operations, this may be perfectly fine.
For long-lived processes, we need:
expiration
revocation checks
short leases
token rotation
This is why type safety doesn't eliminate runtime security.
It complements it.
28. Don't Turn Everything Into a Type
There is also a danger here.
If every permission becomes a type and every resource relationship becomes a generic parameter, your code can become unreadable.
You might end up with:
Api<
Tenant<
Auth<
Resource<
Permission<
State<
Version<T>
>
>
>
>
>
>
Congratulations.
You have created a programming language inside your API.
Type systems are powerful.
But complexity is also a security risk.
The goal isn't:
Put everything into types.
The goal is:
Use types where they make important invariants obvious and difficult to violate.
29. A Practical Hybrid Model
A realistic system might divide responsibilities like this:
Compile time
Enforce:
operation requires capability
valid state transitions
resource ownership abstractions
safe authority propagation
Runtime
Enforce:
identity
token validity
expiration
revocation
tenant membership
business policy
resource existence
contextual conditions
The architecture becomes:
Compile Time
|
+-------------+-------------+
| |
Type Safety API Contracts
| |
+-------------+-------------+
|
v
Runtime
|
+-------------+-------------+
| | |
Identity Policy State
| | |
+-------------+-------------+
|
v
Authorization
|
v
Capability
|
v
Operation
This is a much more realistic approach.
30. The API Signature Becomes a Security Document
This is probably my favorite part.
Consider:
fn refund_payment(
capability: RefundPayment,
payment: Payment
) -> Result<Refund, Error>
That function signature tells us something important.
It says:
Refunds require authority.
Compare it to:
fn refund_payment(
payment: Payment
) -> Result<Refund, Error>
Now the permission requirement is invisible.
A developer reading the first version immediately sees the security boundary.
The type signature becomes documentation.
But better than documentation:
the compiler can enforce the requirement.
Conclusion: Make Illegal Authority Hard to Express
The traditional API security model is:
Request
|
v
Check permission
|
v
Execute
A type-driven security model becomes:
Request
|
v
Authenticate
|
v
Authorize
|
v
Create capability
|
v
Typed operation
The difference looks small.
Architecturally, it is enormous.
Instead of scattering:
if hasPermission(...)
throughout your application, you make authority part of the interfaces between components.
Instead of passing:
role = "admin"
everywhere, you pass the narrow capability required for the specific operation.
Instead of treating permissions as strings, you can represent them as types.
Instead of allowing every piece of code to attempt every operation and hoping authorization was checked somewhere, you design APIs where sensitive operations require explicit authority.
The result is not perfect security.
A type system cannot know who logged in five seconds ago.
It cannot know whether an administrator revoked someone's access.
It cannot know whether a token has expired.
It cannot replace runtime authorization.
But it can do something incredibly valuable:
It can make the architecture communicate what authority is required.
And it can make certain classes of mistakes harder to express.
That's the deeper lesson.
Good type systems don't merely prevent invalid data.
They can encode valid states, valid transitions, ownership, capabilities, and authority.
And when those ideas reach API design, the API becomes more than a collection of endpoints.
It becomes a security boundary that is visible in the code itself.
The most interesting question isn't:
"How do I check whether this user has permission?"
It's:
"Can I design this API so that unauthorized behavior is difficult to express in the first place?"
That is a very different way of thinking about backend architecture.
And perhaps the future of secure APIs isn't simply better authorization middleware.
Maybe it's APIs where permission is part of the type system itself.
Top comments (0)