September 15, 2026
Authentication Is Not Authorization: The API Security Gap We Still Miss
We spend a lot of engineering effort answering one security question:

By Kovid
7 min read
Who is making this request?
We have OAuth, JWTs, API gateways, identity providers, MFA, certificates, device verification, request signing, and increasingly sophisticated ways of establishing trust.
All of that matters.
But there is another question that is just as important, and in many API designs much easier to get wrong:
Is this user actually allowed to perform this action on this specific resource?
That is authorization.
The distinction between authentication and authorization sounds obvious. But once systems grow across many APIs, services, teams, and business domains, the line between the two can become surprisingly easy to blur.
A valid token does not mean valid access
Consider a simple API:
GET /api/orders/839271
The caller provides a valid token.
The signature checks out.
The token has not expired.
The gateway accepts the request.
The service successfully establishes the identity of the caller.
So far, everything is working exactly as designed.
But one question is still unanswered:
Does this user actually have permission to access Order 839271?
Now suppose the caller changes the request:
GET /api/orders/839272
If the service simply retrieves that order because the caller has already authenticated, authentication has succeeded while authorization has failed.
This is the core idea behind Insecure Direct Object Reference (IDOR) and the broader problem commonly described as Broken Object Level Authorization (BOLA).
I like to reduce it to one simple statement:
Being authenticated does not make every resource accessible to you.
That sounds straightforward.
At scale, however, that distinction becomes an architecture problem.
Where the gap usually appears
A modern request may already pass through several layers:
Client โ API Gateway โ Authentication โ Token Validation โ Service โ Database
When we see several security controls in front of an API, it is natural to assume that the request has already been secured.
But most of those controls primarily answer some variation of:
Can I trust who this caller claims to be?
They do not necessarily answer:
Can this caller access this particular object?
A more complete flow is closer to:
Client โ Authenticate โ Establish Principal โ Identify Resource โ Identify Action โ Authorize โ Execute
That authorization decision should be explicit.
It should not be implied simply because authentication happened earlier in the request.
Authorization is more contextual than authentication
Imagine a token tells us:
userId: 12345
role: customer
scope: orders.readuserId: 12345
role: customer
scope: orders.readThe caller requests:
GET /orders/839271GET /orders/839271The token may establish that User 12345 is a customer and has permission to use order-reading functionality.
But it still doesn't tell us whether Order 839271 belongs to that user.
This is why authorization often exists at several levels.
A user may be allowed to call an endpoint but not access every object behind that endpoint.
A user may be allowed to read an object but not modify it.
A merchant may be allowed to view transactions, but only transactions associated with that merchant.
A support employee may be allowed to work with customer cases, but perhaps not every customer record in the organization.
This is where simple role checks start becoming insufficient.
Authorization may depend on the relationship between:
the principal, the action, the resource, and the context.
In other words:
Remember PARC โ Can principal P perform action A on resource R under context C?
That is a more useful way to think about authorization than asking only:
Does this user have Role X?
Some might say lets use UUIDs, they help, but they do not solve authorization
One common response to IDOR risk is to replace sequential identifiers with UUIDs.
Instead of:
10001
10002
10003
we expose an identifier that is much harder to predict.
That is useful because it makes resource enumeration significantly more difficult.
But identifier complexity is still not authorization.
Identifiers can appear in URLs, logs, API responses, analytics, events, browser history, client-side storage, screenshots, or many other places.
If knowing an identifier is enough to retrieve the resource, the system is depending on the secrecy of that identifier.
That is not a strong security boundary.
A safer engineering assumption is:
Every resource identifier coming from a client should be treated as untrusted input.
Whether it is an integer, UUID, transaction ID, document ID, account ID, or something else, the system should still be able to answer:
Why is this caller allowed to operate on this resource?
Resource lookup is not authorization
This distinction also shows up in code.
A simple implementation may look like:
order = orderRepository.findById(orderId)
return orderorder = orderRepository.findById(orderId)
return orderThat answers:
Does this order exist?
It does not necessarily answer:
Should this caller be able to see it?
Authorization needs to happen deliberately.
Conceptually, it could look like:
principal = authenticatedPrincipal()
order = orderRepository.findById(orderId)
authorizationService.verify(
principal,
READ,
order
)
return orderprincipal = authenticatedPrincipal()
order = orderRepository.findById(orderId)
authorizationService.verify(
principal,
READ,
order
)
return orderIn other systems, authorization may be incorporated into the way the resource is retrieved:
orderRepository.findByIdAndCustomerId(
orderId,
principal.customerId
)orderRepository.findByIdAndCustomerId(
orderId,
principal.customerId
)There is no single implementation pattern that works for every domain.
The principle is more important:
Object retrieval and object authorization should not accidentally become the same thing.
And if security depends on every developer remembering to add the right condition to the right query, that becomes difficult to scale reliably across an organization.
This is where authorization becomes an architecture problem
Authorization becomes more interesting as systems become distributed.
Relationships change.
A user may lose access.
An employee may move to another organization.
An assignment may end.
An account may become suspended.
A resource may change ownership.
Permissions may be revoked.
Now imagine that the service making the authorization decision is working from cached or replicated information.
The authorization logic itself can be completely correct while the data it is using is stale.
At that point, authorization is no longer only an application-security concern.
It becomes a distributed-systems problem.
Now we have to think about questions such as:
- Where is the source of truth?
- How quickly are authorization changes propagated?
- Can authorization information be cached?
- How much staleness is acceptable?
- What happens when an authorization dependency is unavailable?
That last question is particularly important.
If an authorization dependency times out, does the system fail open and allow the request? Or fail closed and deny it?
For some low-risk operations, limited degradation may be acceptable. For other operations, proceeding without a reliable authorization decision may be unacceptable.
The important point is that these should be intentional architectural decisions, not accidental consequences of a timeout or error handler.
The bigger scaling problem: relying on developers to remember
A security control that depends entirely on every developer remembering to implement it correctly will eventually fail somewhere.
This is where the problem moves beyond individual APIs.
The question becomes:
How do we make correct authorization the default across the organization?
The answer is not necessarily to create one centralized service containing every authorization rule for every application. That can create a different kind of coupling. Instead, I think there is an important separation to make:
Standardize the authorization mechanism. Keep domain-specific authorization knowledge close to the domain that owns it.
At a high level, the authorization contract can be common:
Principal
+
Action
+
Resource
+
Context
โ
Policy Evaluation
โ
PERMIT / DENYPrincipal
+
Action
+
Resource
+
Context
โ
Policy Evaluation
โ
PERMIT / DENYThe platform can provide the reusable capabilities:
- principal extraction;
- consistent policy invocation;
- default-deny behavior;
- standard enforcement hooks;
- telemetry and audit signals;
- common error handling;
- testing utilities;
- CI/CD validation.
The application still provides the domain-specific facts.
For an order system, the relevant relationship might be:
customerId == order.customerIdcustomerId == order.customerIdFor a document system:
user belongs to document.organizationuser belongs to document.organizationFor a task system:
user is assigned to taskuser is assigned to taskThe common framework does not need to understand what an order, document, or task means.
It needs a consistent way to ask:
Can this principal perform this action on this resource under this context?
That distinction matters.
The organization owns the authorization pattern.
The domain owns the authorization rule.
Make the secure path the easiest path
Shared libraries are a good start.
But a shared library still depends on someone remembering to call it.
As systems mature, authorization can move deeper into the engineering framework through middleware, filters, interceptors, annotations, policy enforcement points, service templates, or similar patterns. If not maintained correctly, it might still create gaps of authorization from systems implementing without standardization
Conceptually, an endpoint might declare:
@Authorize(
resource = "ORDER",
action = "READ"
)@Authorize(
resource = "ORDER",
action = "READ"
)The framework can then take responsibility for common behavior such as establishing the principal, evaluating the policy, enforcing the outcome, and recording appropriate telemetry.
The goal is not to hide authorization from developers.
The goal is to stop making security dependent on repetitive implementation.
That changes the engineering model from:
"Remember to secure this endpoint."
to:
"This endpoint is secured by default. Explain why it should not be."
That is a much stronger default for a large engineering organization.
Authorization can eventually become part of:
- service templates;
- API standards;
- architecture reviews;
- CI/CD;
- automated security testing;
- observability;
- runtime enforcement.
At that point, authorization is no longer something each service independently reinvents.
It becomes an engineering capability.
We should test relationships, not only endpoints
Many API tests follow a familiar pattern:
Call the endpoint.
Verify a successful response.
Validate the payload.
That proves the API works.
It does not necessarily prove that the authorization boundary works.
One of the simplest authorization tests is also one of the most valuable.
Create User A and User B.
Give each user their own resources.
Authenticate as User A.
Then deliberately attempt to access or modify User B's resources.
Those requests should fail.
The same idea extends naturally to:
- cross-tenant access;
- organization boundaries;
- merchant boundaries;
- privileged actions;
- state-dependent permissions.
These tests validate the relationship between the caller and the resource, rather than just whether the endpoint functions.
Authorization deserves regression testing just like any other critical application behavior.
Authorization should be observable too
Another area that is easy to overlook is visibility.
A single 403 Forbidden may look like an ordinary application response.
Thousands of authorization failures across many different resource identifiers can tell a very different story.
Useful signals can include patterns such as:
- unusual increases in authorization denials;
- repeated attempts across many resources;
- cross-tenant access attempts;
- unexpectedly high denial rates for an API or client;
- authorization behavior changing significantly after a deployment.
Telemetry itself needs to be designed carefully so sensitive information is not unnecessarily exposed.
But authorization is not only a runtime decision.
It can also become an important security signal.
Four questions I would ask for any resource API
Whenever an API accepts a resource identifier, I find four questions useful.
1. Who is making the request? Authentication.
2. What resource are they requesting? Resource identification.
3. What are they trying to do? Action.
4. Why is this principal allowed to perform this action on this specific resource? Authorization.
Most mature systems have strong answers to the first three.
The fourth deserves the same level of architectural attention.
The broader engineering lesson
Security systems continue to become more sophisticated.
We have stronger authentication, better device trust, certificate validation, API gateways, request signing, threat detection, rate limiting, and many other controls.
Those controls are valuable.
But they solve different problems.
A request can be authentic and still be unauthorized.
A legitimate device can still request another user's resource.
A correctly signed request can still attempt an operation the caller should not be allowed to perform.
That is why authentication and authorization need to remain clearly separated in API design.
The simplest definition is still the most useful:
Authentication tells us who you are.
Authorization determines what you are allowed to do.
And for resource-oriented APIs, I would add one more question:
To which specific resource?
As organizations grow from a few APIs to hundreds of services, object-level authorization cannot remain something every developer is simply expected to remember.
It needs to become part of how APIs are designed, tested, observed, and eventually standardized across the engineering platform.
Because good security controls are not just well documented.
They are difficult to accidentally bypass.