TT Lab
Get started
Learn Learning paths Courses

Keycloak and Enterprise Identity

IDOR Is a Failure of Authorization, Not Authentication

Continue in TT Lab

Summary

Authentication is "verifying who you are", and authorization is "deciding whether this person may perform this action on this resource". Most practical incidents come from the latter.

Why this was needed

There are many cases where the login is built well and an incident still happens. The user is properly authenticated and the token is valid. But if you change the number in /api/orders/1042 to 1043, someone else's order appears.

This is IDOR (Insecure Direct Object Reference). Authentication was passed but authorization was not performed. Nobody checked "is the owner of this token the owner of this order?".

How it works

You need the habit of thinking about the two questions separately in code.

Authentication is done once at the request entry point. You verify the token signature, check the expiry, and extract the subject (sub). This step can be handled in middleware and is independent of any resource.

Authorization is done at every point that accesses a resource. And since you can decide only by knowing the resource's owner or attributes, middleware alone is not enough. "Is the owner_id of order 1043 the same as the current user's sub?" can be decided only after reading that order.

Here one practical rule follows. Put the owner condition in the lookup query itself. If you write SELECT * FROM orders WHERE id=? AND owner_id=?, someone else's order yields no result in the first place. It is harder to get wrong than comparing after reading.

It is also worth distinguishing roles from permissions. A role is a label attached to a person (for example, order-admin), and a permission is a name attached to an action (for example, order:refund). If you build authorization on roles alone, the number of roles explodes, and if you build it on permissions alone, it is hard to manage. In practice, you usually bundle permissions into roles and give roles to users.

Where to put authorization

If you scatter the same rule across several layers, sooner or later they diverge. Choose one of three places and use the others only as support.

Place What it does well What it cannot do
Gateway Blocking by path and method, verifying authentication It does not know the owner of a resource
Service code Decisions based on owner and state The rules get scattered in code
Policy engine (OPA, etc.) Gathers rules in one place and audits them One more round trip

The practical default is authentication at the gateway, authorization in the service. The gateway only looks at whether the token is valid, and "can this person see this order?" is decided by the service that knows that order.

A structure that does not forget permission checks

If you lean on human attention, you will eventually miss something. Block it with structure.

Make the default deny. When you create a new endpoint, it should return 403 when there is no decorator at all. If allow is the default, the place you missed is a hole.

@router.get("/orders/{oid}")
@requires("order:read")          # 없으면 라우터 등록 단계에서 거부
def get_order(oid: int, user=Depends(current_user)):
    # 조회 자체에 소유자 조건을 넣는다 — 읽은 뒤 비교하는 것보다 실수하기 어렵다
    row = db.one("select * from orders where id=%s and owner_id=%s", (oid, user.sub))
    if not row:
        raise HTTPException(404)   # 403 이 아니라 404 — 존재 여부도 흘리지 않는다
    return row

The last line is important. Giving a 403 for someone else's resource tells them "that number exists". In a system that uses sequential IDs, this alone leaks the overall scale. It is better to give a 404 as if it did not exist.

The same condition must go into list queries too. An incident where the detail view is blocked but the list returns everything is common. So you make the lookup function itself take an owner argument, and do not keep any function that does not take one.

One more layer in multi-tenancy

If several organizations use one system, owner_id alone is not enough. This is because there are resources that other people in the same organization must be able to see. You put tenant_id in every table and have a layer that is forcibly attached to every query. With PostgreSQL, you can also have the DB enforce it directly with Row Level Security.

alter table orders enable row level security;
create policy tenant_isolation on orders
  using (tenant_id = current_setting('app.tenant_id')::uuid);

Even if the application makes a mistake, the DB blocks it. The cost is the trouble of calling set_config for every connection, and the care needed so that the setting does not leak in a connection pool.

What you meet in the field

There is also a choice between putting roles in the token and looking them up on the server on every request. Putting them in the token is fast, but even if you revoke a role, the token remains valid until it expires. That is why keeping access tokens short matters for authorization too.

Another place people often go wrong. Hiding a button in the frontend is not authorization. The UI is a convenience, and the decision must be made again on the server. The situation of "a screen only administrators can see, but the API is open" is what comes up first in penetration tests.

What to look at in the next check

First, in the quiz, you check whether you can tell a successful authentication from a successful resource authorization. In the next module, after laying out the landscape of OAuth2 grant types, we move on to a real Keycloak.