← News
/PLAYBOOK · 2026-09-14

Broken Access Control Defense in Practice: IDOR API Authorization

IDOR only needs swapping a resource ID in the request to read someone else's data. Reproduce horizontal and vertical IDOR with two accounts and curl. Service-layer object auth (owner predicate in WHERE), UUID public IDs, Spring Security annotations, MyBatis examples, gateway fallback, CI regression.

Scope

Broken Access Control — often as IDOR, Insecure Direct Object Reference — has sat at the top of OWASP Top 10 for years. Typical shape: the API knows who you are, then never checks whether you may touch this row. Change order 1001 to 1002, or swap `user_id`, and you get someone else's order, invoice, chat, or admin action.

Highest hit rate: APIs that expose auto-increment PKs as public IDs; list endpoints filter by auth, detail endpoints do not; frontend hides buttons or greys menus while the backend has no check; after a microservice split, each service assumes the gateway already authorized; export/download/preview treated as "just a read" and skipped.

Defense is three layers: object-level auth on the API, owner predicates at the data layer, gateway deny for unauthorized paths. Includes reproducible tests and an engineering self-check list.

Prerequisites

Internals

Two classes, two test methods:

Shared root cause: treating client-controlled fields as trusted. Resource ID, role, tenant ID all come from the client. If the server concatenates them into the query, the attacker rewrites the auth decision. Move the decision from "what the client sent" to "what the server took from the session", and force an owner predicate at the data-access layer.

Three questions flag IDOR: does this API take a resource ID? can the client pick that ID freely? does the server check owner vs current principal before fetch? Yes to all three, risk holds.

Procedure

Step 1: Reproduce with two accounts

Log in as A, take A's token, read A's orders. Then use B's token against A's order ID:

# user A login: own token and order list
curl -s -X POST https://helixcyber.ai/login \
     -H 'Content-Type: application/json' \
     -d '{"user":"alice","pass":"alice_pwd"}' | tee /tmp/a.json
TOKEN_A=$(python3 -c "import json;print(json.load(open('/tmp/a.json'))['token'])")
curl -s https://helixcyber.ai/api/v1/orders -H "Authorization: Bearer $TOKEN_A"

# user B login: try A's order detail (swap to A's order id)
curl -s -X POST https://helixcyber.ai/login -H 'Content-Type: application/json' \
     -d '{"user":"bob","pass":"bob_pwd"}' | tee /tmp/b.json
TOKEN_B=$(python3 -c "import json;print(json.load(open('/tmp/b.json'))['token'])")
curl -s -o /dev/null -w 'B reads A order HTTP=%{http_code}\n' \
     https://helixcyber.ai/api/v1/orders/1001 -H "Authorization: Bearer $TOKEN_B"

Pass/fail is binary: HTTP 200 with A's order body is horizontal IDOR; 403 or 404 is correct. Prefer 404 over 403 when existence itself is sensitive — 403 leaks that the ID exists.

Vertical IDOR, same pattern: A's token against admin APIs:

curl -s -o /dev/null -w 'A hits admin API HTTP=%{http_code}\n' \
     -X DELETE https://helixcyber.ai/api/v1/admin/users/2 \
     -H "Authorization: Bearer $TOKEN_A"
curl -s -o /dev/null -w 'A hits export API HTTP=%{http_code}\n' \
     https://helixcyber.ai/api/v1/admin/export/users -H "Authorization: Bearer $TOKEN_A"

Step 2: Add object-level auth in the service layer

Python (Flask-style): fetch with owner in the query, then return. Put the check in the WHERE, not after the fetch:

from flask import request, g, abort, jsonify

def current_user_id():
    # trust only server session / gateway-injected principal; never read user_id from the body
    return g.user_id

@app.get("/api/v1/orders/<int:order_id>")
def get_order(order_id):
    # anti-pattern: fetch then compare owner; exception paths skip the check
    # order = db.query(Order).get(order_id)
    # if order.owner_id != current_user_id(): abort(403)

    # correct: owner_id in WHERE; unauthorized query is empty at the DB
    order = db.query(Order).filter(
        Order.id == order_id,
        Order.owner_id == current_user_id(),
    ).first()
    if order is None:
        abort(404)          # always 404; do not distinguish missing vs forbidden
    return jsonify(order.to_dict())

Three rules: `current_user_id` comes only from session (`g`, context, JWT claim), never `request.args.get("user_id")`. Owner predicate lives in WHERE so a successful fetch cannot skip the compare. Missing and forbidden both return 404 so status codes cannot enumerate IDs.

Java (Spring Security): function-level auth via annotation; object-level auth still hand-checked in the service:

@PreAuthorize("hasAuthority('order:read')")
public OrderVO getOrder(Long orderId) {
    Long uid = SecurityUtils.getCurrentUserId();   // from SecurityContext
    OrderVO vo = orderMapper.selectByIdAndOwner(orderId, uid);
    if (vo == null) {
        throw new BizException(404, "resource not found");
    }
    return vo;
}

Matching MyBatis must include the owner predicate. Never select by PK alone:

<select id="selectByIdAndOwner" resultType="OrderVO">
    SELECT id, order_no, amount, status
      FROM t_order
     WHERE id = #{orderId}
       AND owner_id = #{uid}
       AND deleted = 0
</select>

Step 3: Switch public IDs to non-enumerable values

Auto-increment IDs invite scripted walks. Expose UUID or a prefixed random string. Keep the internal auto-increment PK for joins:

ALTER TABLE t_order ADD COLUMN public_id CHAR(26) NOT NULL UNIQUE;
-- MySQL generate: SELECT REPLACE(UUID(),'-','') or ULID in the app
# generate on the container
python3 -c "import uuid;print(uuid.uuid4().hex)"

Map at the API boundary: internal `id` never enters a response body or frontend route. Clients see `public_id` only. This does not replace auth. It raises scan cost and makes enumeration obvious in audit logs.

Step 4: Gateway fallback deny

Deny sensitive paths at the gateway or reverse proxy so one missed service config cannot open the estate. Nginx example: admin and export only from internal management nets:

location ~* ^/(api/v1/)?(admin|manage|export)/ {
    allow 10.10.0.0/16;      # office net
    allow 172.16.8.0/24;     # jump-host net
    deny  all;               # everyone else 403
    proxy_pass http://backend;
    proxy_set_header X-Real-IP $remote_addr;
}

Rate-limit to blunt bulk enumeration:

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;

location /api/ {
    limit_req zone=api_limit burst=40 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

Tune `rate=20r/s` and `burst=40` from real traffic. Export APIs get a lower cap (e.g. 2r/s); normal users do not hammer export.

Step 5: Static grep in the repo

Regex-hunt PK-only fetches as a standing review step:

# PK-only queries missing an owner predicate
grep -rnE "(get|find|select)ById\(" --include=*.java src/ | grep -v "Owner"

# auth decisions from client-supplied user_id / role
grep -rnE "(request\.(args|form|json)\.(get|\\[))\(?[\"'](user_id|role|tenant_id|is_admin)" \
     --include=*.py --include=*.js .

# commented-out auth (common regression)
grep -rnE "//\\s*(check|verify|assert).*(permission|owner|access)" --include=*.java src/

Verification

After the fix, regress status codes one by one:

# expect: B reads A's resource = 404, B reads own = 200
curl -s -o /dev/null -w 'B->A  %{http_code}\n' https://helixcyber.ai/api/v1/orders/1001 -H "Authorization: Bearer $TOKEN_B"
curl -s -o /dev/null -w 'B->B  %{http_code}\n' https://helixcyber.ai/api/v1/orders/2001 -H "Authorization: Bearer $TOKEN_B"

# expect: regular user hits admin = 403
curl -s -o /dev/null -w 'A->admin %{http_code}\n' -X DELETE https://helixcyber.ai/api/v1/admin/users/2 -H "Authorization: Bearer $TOKEN_A"

# expect: spoofed body user_id is ignored
curl -s -o /dev/null -w 'spoof %{http_code}\n' https://helixcyber.ai/api/v1/orders/1001 \
     -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"user_id":7}'

Cover three extra edges: update/delete (`PUT`/`DELETE` IDOR is worse), batch APIs (`ids=[1,2,3]` mixed with foreign IDs must reject the batch or filter), export/preview (often skipped as read-only). After green, freeze these curl cases into the CI API suite so later diffs cannot regress.

FAQ

Q1: The frontend already hid the button. Why is this still IDOR?

Frontend runs in the attacker's browser. Hidden buttons, grey menus, route guards all fold — one JS edit, or curl. UI is UX, not access control. The only test: craft the request with no frontend change; does the server still deny? Permission must land on the server API. Frontend hide is polish.

Q2: After UUID, can we skip auth?

No. Random IDs raise enumeration cost. They do not stop IDOR: share links, logs, export files, frontend cache, third-party SDK reports still leak other people's UUIDs. UUID is depth. Auth is the control. Both required. Treat any ID that ever appeared in a response as leaked. UUID is not a sole control.

Q3: Internal service-to-service calls have no user session. How do you do object-level auth?

Internal services still need a caller identity, passed and checked: (1) mTLS, identity from cert CN/SAN; (2) short-lived tokens from mesh or gateway (caller service name + scope); (3) end-user identity in a signed header (JWT); callee verifies signature then reads user ID for the owner check. Never accept unsigned plaintext `X-User-Id`. Intranet trust is not a security boundary. One compromised service turns unauthenticated internal APIs into a lateral hop.

Summary

IDOR is handing the auth decision to the client. Four engineering constraints: principal only from server session, never from client identity fields; owner predicate in the query, not after fetch; missing and forbidden both 404; sensitive paths get a second deny at the gateway. Ship in order: inventory → two-account reproduce → service-layer auth → gateway fallback → CI cases. That turns one-off patches into a standing control.