DEV Community

Cover image for Designing APIs Where Permissions Are Part of the Type System
Derek Mwale
Derek Mwale

Posted on

Designing APIs Where Permissions Are Part of the Type System

Most APIs treat permissions as something that happens after the code has already decided what it wants to do.

You write:

deleteUser(userId)
Enter fullscreen mode Exit fullscreen mode

Then somewhere inside the function:

if (!user.canDelete) {
    throw ForbiddenError();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

we could think:

permission + operation = valid program
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The server receives the request.

It authenticates the caller.

Then it checks:

Is this user an administrator?
Enter fullscreen mode Exit fullscreen mode

The architecture might look like:

Client
  |
  v
API
  |
  v
Authentication
  |
  v
Authorization
  |
  v
Business Logic
  |
  v
Database
Enter fullscreen mode Exit fullscreen mode

This is normal.

But notice something.

The business operation exists independently from the permission.

We have:

deleteUser()
Enter fullscreen mode Exit fullscreen mode

and separately:

canDeleteUser()
Enter fullscreen mode Exit fullscreen mode

The programmer has to remember to connect them.

That creates a dangerous class of bugs:

deleteUser()
Enter fullscreen mode Exit fullscreen mode

gets called from somewhere that forgot:

canDeleteUser()
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Then we might write:

if (user.permissions.includes("user.delete")) {
    deleteUser(id);
}
Enter fullscreen mode Exit fullscreen mode

This is convenient.

But strings have almost no compile-time meaning.

The compiler doesn't know that:

"user.delete"
Enter fullscreen mode Exit fullscreen mode

is related to:

deleteUser()
Enter fullscreen mode Exit fullscreen mode

You could accidentally write:

"user.deleet"
Enter fullscreen mode Exit fullscreen mode

and the compiler might happily accept it.

You could also call:

deleteUser()
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Now we could define an operation requiring a specific capability:

fn delete_user(
    permission: DeleteUser,
    user_id: UserId,
) -> Result<(), Error> {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The interesting thing isn't the struct itself.

The interesting thing is:

Where does DeleteUser come 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()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Some other code tries:

delete_user(UserId(42));
Enter fullscreen mode Exit fullscreen mode

It doesn't compile.

The function requires:

DeleteUser
Enter fullscreen mode Exit fullscreen mode

not:

UserId
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It becomes partially:

compile-time structure
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Possession of that object means:

you are authorized to perform this operation
Enter fullscreen mode Exit fullscreen mode

So:

User identity
      |
      v
Authorization policy
      |
      v
Capability
      |
      v
Operation
Enter fullscreen mode Exit fullscreen mode

The capability becomes a token of authority.

This is different from passing around:

role = "admin"
Enter fullscreen mode Exit fullscreen mode

because a role is broad.

A capability can be extremely specific.

For example:

DeleteUserCapability<User42>
Enter fullscreen mode Exit fullscreen mode

or:

WriteDocumentCapability<Document17>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and documents:

Document 1 -> Tenant A
Document 2 -> Tenant B
Enter fullscreen mode Exit fullscreen mode

A common security bug looks like:

GET /documents/2
Enter fullscreen mode Exit fullscreen mode

where the current user belongs to Tenant A.

The application forgets to add:

WHERE tenant_id = current_tenant
Enter fullscreen mode Exit fullscreen mode

Now Tenant A can see Tenant B's data.

Instead, imagine:

struct TenantId<T>;

struct Document<Tenant> {
    id: DocumentId,
    tenant: PhantomData<Tenant>,
}
Enter fullscreen mode Exit fullscreen mode

The type system can begin carrying tenant context.

Conceptually:

Document<TenantA>
Enter fullscreen mode Exit fullscreen mode

is not the same type as:

Document<TenantB>
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Now we can define:

type Authorized<P extends Permission> = {
    permission: P;
};
Enter fullscreen mode Exit fullscreen mode

Then:

function deleteUser(
    auth: Authorized<"user.delete">,
    userId: string
) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The caller needs a value proving that it has:

"user.delete"
Enter fullscreen mode Exit fullscreen mode

We can then build an authorization function:

function authorize<P extends Permission>(
    token: Token,
    permission: P
): Authorized<P> | null {
    // runtime authorization
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const capability =
    authorize(token, "user.delete");

if (capability) {
    deleteUser(capability, userId);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then:

struct Capability<P> {
    _permission: std::marker::PhantomData<P>,
}
Enter fullscreen mode Exit fullscreen mode

Our API might expose:

fn read_user(
    _: Capability<ReadUser>,
    id: UserId,
) -> User {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

and:

fn delete_user(
    _: Capability<DeleteUser>,
    id: UserId,
) -> Result<(), Error> {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Now these operations have explicit authority requirements.

The function signature tells the truth.

Reading requires:

Capability<ReadUser>
Enter fullscreen mode Exit fullscreen mode

Deleting requires:

Capability<DeleteUser>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

A capability-oriented API might conceptually describe:

delete_user(
    DeleteUserCapability,
    UserId
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That's much harder to misunderstand.


10. Permissions Can Form a Type Hierarchy

Permissions aren't always independent.

Suppose:

Admin
Enter fullscreen mode Exit fullscreen mode

implies:

ReadUser
WriteUser
DeleteUser
Enter fullscreen mode Exit fullscreen mode

We could model capabilities hierarchically.

Conceptually:

AdminCapability
      |
      +----> ReadUser
      +----> WriteUser
      +----> DeleteUser
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

object, we could derive:

DeleteUser
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

So instead of giving it:

Admin
Enter fullscreen mode Exit fullscreen mode

give it:

ChargePayment
Enter fullscreen mode Exit fullscreen mode

Now the architecture becomes:

Payment Service
       |
       v
ChargePaymentCapability
Enter fullscreen mode Exit fullscreen mode

If the service is compromised, its authority is limited.

This is much safer than:

Payment Service
       |
       v
SuperAdmin
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

then the system is useless.

The constructor must be protected.

For example:

pub struct DeleteUser {
    private: (),
}
Enter fullscreen mode Exit fullscreen mode

Only the authorization subsystem can create it.

Conceptually:

External Code
     |
     X
Cannot construct capability
Enter fullscreen mode Exit fullscreen mode

but:

Authorization Layer
     |
     v
Creates capability
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

we can think:

authorization()
      |
      v
proof
      |
      v
operation(proof)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

can be interpreted as:

"This code possesses authority to perform user deletion."

The function doesn't need to repeatedly ask:

Are you allowed?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

at compile time.

It cannot know:

whether Alice revoked Bob's access five seconds ago
Enter fullscreen mode Exit fullscreen mode

It cannot know:

whether this JWT has expired
Enter fullscreen mode Exit fullscreen mode

Those are runtime facts.

So the architecture becomes:

Runtime
   |
   | authenticate
   v
Authorization
   |
   | establish capability
   v
Type-safe business logic
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The server cannot trust the client to provide:

DeleteUserCapability
Enter fullscreen mode Exit fullscreen mode

The capability exists internally.

The external request is merely a request.

The server authenticates:

Who are you?
Enter fullscreen mode Exit fullscreen mode

Then authorizes:

Are you allowed to delete user 42?
Enter fullscreen mode Exit fullscreen mode

Then creates:

DeleteUserCapability
Enter fullscreen mode Exit fullscreen mode

Then invokes:

delete_user(capability, 42)
Enter fullscreen mode Exit fullscreen mode

The architecture is:

HTTP Request
     |
     v
Authentication
     |
     v
Authorization
     |
     v
Capability
     |
     v
Typed Business Logic
Enter fullscreen mode Exit fullscreen mode

This is the important distinction between external permissions and internal authority.


16. Resource-Level Permissions

Global permissions aren't always enough.

Consider:

document.read
Enter fullscreen mode Exit fullscreen mode

A user might have permission to read documents generally, but not every document.

We need:

document.read(document_id)
Enter fullscreen mode Exit fullscreen mode

Now the capability could carry resource identity:

struct ReadDocument {
    document_id: DocumentId,
}
Enter fullscreen mode Exit fullscreen mode

Then:

fn read_document(
    capability: ReadDocument
) -> Document {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Owned<Document, TenantA>
Enter fullscreen mode Exit fullscreen mode

cannot be casually passed where:

Owned<Document, TenantB>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Not every operation is valid in every state.

For example:

cancel()
Enter fullscreen mode Exit fullscreen mode

may only be allowed when:

PENDING
Enter fullscreen mode Exit fullscreen mode

We can model this with types:

struct PendingOrder;
struct PaidOrder;
struct ShippedOrder;
Enter fullscreen mode Exit fullscreen mode

Then:

fn cancel(
    order: Order<PendingOrder>,
    capability: CancelOrder,
) -> CancelledOrder {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

But:

fn cancel(
    order: Order<ShippedOrder>,
    capability: CancelOrder,
)
Enter fullscreen mode Exit fullscreen mode

could be impossible to compile.

Now we have combined:

state
+
permissions
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Type-driven API:

CreateOrder
     |
     v
PendingOrder
     |
     +----> PayOrder
     |
     v
PaidOrder
     |
     +----> ShipOrder
     |
     v
ShippedOrder
Enter fullscreen mode Exit fullscreen mode

Each transition can require a capability.

PayOrderCapability
ShipOrderCapability
CancelOrderCapability
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

The operation checks:

if capability.expires_at < now() {
    return Err(Error::ExpiredCapability);
}
Enter fullscreen mode Exit fullscreen mode

Now we have:

type-level authority
+
runtime temporal validity
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Instead of passing around:

UserContext
Enter fullscreen mode Exit fullscreen mode

everywhere, we can pass narrower capabilities.

For example:

Order Service
    |
    +---- ReadCustomer
    |
    +---- CreateOrder
    |
    +---- ReadInventory
Enter fullscreen mode Exit fullscreen mode

The Payment Service gets:

ChargePayment
Enter fullscreen mode Exit fullscreen mode

but not:

DeleteCustomer
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

separate from:

architecture
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

immediately tells you:

This function has financial authority.

And:

fn read_customer(
    capability: ReadCustomer,
    customer_id: CustomerId
)
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Adding capabilities extends the philosophy:

Who owns this?
Who can mutate this?
Who is authorized to perform this operation?
Enter fullscreen mode Exit fullscreen mode

Imagine:

fn update_document(
    capability: WriteDocument,
    document: &mut Document,
) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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": "..."
}
Enter fullscreen mode Exit fullscreen mode

Service B validates the token.

Then internally creates:

OrderReadCapability
OrderCreateCapability
Enter fullscreen mode Exit fullscreen mode

Now the architecture becomes:

Service A
   |
   | signed authorization
   v
Service B
   |
   | validate
   v
Typed capability
   |
   v
Business logic
Enter fullscreen mode Exit fullscreen mode

This bridges distributed authorization with type-safe local authority.


25. The Capability Should Be Narrow

One of the biggest mistakes would be creating:

SuperAdminCapability
Enter fullscreen mode Exit fullscreen mode

and passing it everywhere.

Then we've simply reinvented roles.

The goal is to create narrow capabilities.

Instead of:

Admin
Enter fullscreen mode Exit fullscreen mode

use:

ReadUser
WriteUser
DeleteUser
CreateOrder
ChargePayment
RefundPayment
Enter fullscreen mode Exit fullscreen mode

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       |
              +----------------+
Enter fullscreen mode Exit fullscreen mode

The key transition is:

authorization result
        |
        v
typed authority
Enter fullscreen mode Exit fullscreen mode

That is where the architecture changes.


27. What Happens When Permissions Change?

Suppose Alice has:

WriteDocument
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
                    >
                >
            >
        >
    >
>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Runtime

Enforce:

identity
token validity
expiration
revocation
tenant membership
business policy
resource existence
contextual conditions
Enter fullscreen mode Exit fullscreen mode

The architecture becomes:

                 Compile Time
                      |
        +-------------+-------------+
        |                           |
   Type Safety                API Contracts
        |                           |
        +-------------+-------------+
                      |
                      v
                 Runtime
                      |
        +-------------+-------------+
        |             |             |
   Identity      Policy        State
        |             |             |
        +-------------+-------------+
                      |
                      v
                Authorization
                      |
                      v
                 Capability
                      |
                      v
                 Operation
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

That function signature tells us something important.

It says:

Refunds require authority.

Compare it to:

fn refund_payment(
    payment: Payment
) -> Result<Refund, Error>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A type-driven security model becomes:

Request
   |
   v
Authenticate
   |
   v
Authorize
   |
   v
Create capability
   |
   v
Typed operation
Enter fullscreen mode Exit fullscreen mode

The difference looks small.

Architecturally, it is enormous.

Instead of scattering:

if hasPermission(...)
Enter fullscreen mode Exit fullscreen mode

throughout your application, you make authority part of the interfaces between components.

Instead of passing:

role = "admin"
Enter fullscreen mode Exit fullscreen mode

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)