Step 149. Juice Shop 2 — Access Control and IDOR

Step 149. Juice Shop 2 — Access Control and IDOR

Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★☆☆☆ | Estimated time: 2.5 hours

Prerequisites: Step 148 (Juice Shop introduction) complete. You can observe API requests in the developer tools Network tab, and you have re-sent requests with Burp Suite Repeater.

  • What you need: a running Juice Shop (http://localhost:3000), Burp Suite (Community Edition is fine), Python 3 (for local reproduction).
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
  • Legal practice grounds: OWASP Juice Shop is a legal learning platform (a vulnerable web app) officially distributed by the OWASP Foundation "for attack practice," and the Flask server reproducing today’s principles is a local lab running only inside your computer. Do not use today’s techniques anywhere outside these two places.

If yesterday you found the score board and opened hidden paths, today you touch other people’s data in earnest. If my basket’s address is /rest/basket/6, whose basket is /rest/basket/7? If you change one number and someone else’s goods appear, that server checked only "are you logged in" and never checked "are you this data’s owner."

This vulnerability’s name is IDOR (Insecure Direct Object Reference). Because it works with no fancy payload and no difficult tool — just changing a number in the address — it’s also the type most reported in real bug bounties. Today we build an identically vulnerable server in Flask to measure "why it happens," and find a real case in Juice Shop.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain the condition under which IDOR arises ("authenticates but doesn’t authorize")
  • Build a vulnerable order-lookup API in Flask and reproduce the experiment of reading someone else’s data by swapping the number
  • Write defense code that adds an owner check (authorization) and blocks with 403
  • Find and manipulate an API’s ID parameter in Juice Shop with the Network tab and Burp Repeater
  • Verify "hidden in the frontend ≠ blocked on the server" with the admin page case

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 + Flask (local lab), Juice Shop + Burp Suite (wargame)
Today’s commands python server_file.py, browser developer tools Network tab, Burp Repeater’s Send
Concepts needed Authentication vs. authorization, IDOR, horizontal privilege escalation, REST API path parameters
Today’s artifact IDOR vulnerable/defended server practice code + one Juice Shop IDOR success record

2-1. Authentication and Authorization — The Threshold and the Room’s Owner

Authentication is the procedure that confirms "who you are." Login is the classic example. Authorization is the procedure that confirms "whether you may do this." In a hotel analogy, authentication is receiving a key card at the front desk; authorization is that key card opening only room 307.

IDOR is a hotel with an authentication device but no authorization device. With any key card, pressing any room number opens the door. In code it looks like this:

order = db.get(order_id)      # pulls it straight out by number
return jsonify(order)         # never asks who the owner is

The missing line is everything: if order.owner != current_user: deny.

2-2. IDOR — The Attack of Changing One Number

Almost every piece of data in a web service carries a number. Order #1, post #42, member #7. REST APIs often expose this number in the address — like /api/orders/1024, /rest/basket/6.

If the server uses that number "without verification" as a database lookup key, the requester can raise the number by one at a time and sweep through every member’s data. That’s IDOR, and because it’s viewing peers’ data among users of the same rank, it’s also called horizontal privilege escalation. An ordinary user using admin features is distinguished as vertical privilege escalation.

2-3. The Difference Between "Hiding" and "Blocking"

If the admin menu isn’t visible on screen, is the admin feature protected? No. Deleting a button in the frontend is hiding; the server rejecting the request is blocking. Juice Shop’s /administration page is exactly this case — an ordinary account’s screen has no link, but what happens when you type the address directly is something you confirm yourself today.


3. Follow Along

3-1. Building a Vulnerable Order Server

Before entering Juice Shop, we build an identically vulnerable server on our own computer and measure the principle. Create idor_lab.py.

from flask import Flask, jsonify, request

app = Flask(__name__)
app.json.ensure_ascii = False  # keeps non-ASCII from breaking into \uXXXX

ORDERS = {
    1: {"id": 1, "owner": "alice", "item": "Mechanical Keyboard", "price": 89000},
    2: {"id": 2, "owner": "bob", "item": "27-inch Monitor", "price": 320000},
}


@app.route("/order/<int:oid>")
def order(oid):
    # vulnerable version: hands it over by number without asking "who are you"
    o = ORDERS.get(oid)
    if not o:
        return jsonify({"error": "not found"}), 404
    return jsonify(o)


@app.route("/v2/order/<int:oid>")
def order_v2(oid):
    # defended version: checks whether the requester (X-User header) is the owner
    o = ORDERS.get(oid)
    if not o:
        return jsonify({"error": "not found"}), 404
    user = request.headers.get("X-User", "")
    if not user:
        return jsonify({"error": "authentication required"}), 401
    if o["owner"] != user:
        return jsonify({"error": "forbidden: not your order"}), 403
    return jsonify(o)


if __name__ == "__main__":
    app.run(port=5491)

Input

python idor_lab.py

When Running on http://127.0.0.1:5491 appears, the server is alive. This terminal is occupied by the server, so send requests from a new terminal (or another Python window).

3-2. Swapping the Number — Measuring IDOR

From a new terminal, send requests with Python. No login of any kind — just change the number.

import urllib.request
urllib.request.urlopen("http://127.0.0.1:5491/order/1").read().decode()
urllib.request.urlopen("http://127.0.0.1:5491/order/2").read().decode()

Output (measured 2026-09-09):

'{"id":1,"item":"Mechanical Keyboard","owner":"alice","price":89000}\n'
'{"id":2,"item":"27-inch Monitor","owner":"bob","price":320000}\n'

How to read it: I never even logged in, yet bob’s order came out whole. This is the substance of IDOR — so simple it’s embarrassing to call an attack. All I did was change the 1 in the address to 2.

Why: look at the server’s order() function again. It takes oid, pulls from ORDERS, and returns it as-is. Nowhere is there code checking "is the requester this order’s owner." A vulnerability is usually "missing code" like this.

3-3. Measuring the Defense — One Line of Owner Check

The same server’s /v2/ path is the defended version. The request header X-User declares "I am alice" (in a real service, a login token plays this role), and the server compares it against the owner.

import urllib.request, urllib.error

def get(path, user=None):
    req = urllib.request.Request("http://127.0.0.1:5491" + path)
    if user:
        req.add_header("X-User", user)
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, r.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode()

get("/v2/order/2", user="bob")     # the owner themself
get("/v2/order/2", user="alice")   # someone else's order
get("/v2/order/2")                 # no identity

Output (measured 2026-09-09):

(200, '{"id":2,"item":"27-inch Monitor","owner":"bob","price":320000}\n')
(403, '{"error":"forbidden: not your order"}\n')
(401, '{"error":"authentication required"}\n')

How to read it: the owner gets a 200, anyone else is blocked with a 403, and no identity gets bounced with a 401. What was added is one line: if o["owner"] != user. Memorize the status codes’ meanings together — 401 is "who are you?" (authentication failure), 403 is "I know you, but no" (authorization failure).

3-4. Observing the Basket API in Juice Shop

Now to the real practice ground. Create an account in Juice Shop and log in, add any product to the basket, and open the basket screen. Turn on the developer tools (F12) Network tab and refresh, and you’ll see a request like this (screen example — verify the platform’s screens yourself):

GET /rest/basket/6        200   {"id":6,"Products":[...]}

How to read it: the number after /rest/basket/ is my basket number. That number is in exactly the same position as the 1 in /order/1 that we changed in 3-2. Here the attack hypothesis forms itself — "what if I change 6 to 5?"

Why: the core of reconnaissance is finding "APIs with a number exposed in the address." Baskets, order history, reviews, profiles — wherever a number is visible is an IDOR candidate.

3-5. Swapping the ID with Burp Repeater

Send the request you found in the Network tab to Burp. With the browser proxy routed through Burp, refresh the basket, then in Proxy → HTTP history, right-click the GET /rest/basket/6 request → Send to Repeater. In Repeater, edit the address to /rest/basket/5 and press Send (screen example):

HTTP/1.1 200 OK
{"id":5,"Products":[{"name":"Apple Juice", ...}]}

If someone else’s basket contents come back in the response, the IDOR succeeded, and the corresponding challenge gets checked on the score board. If you get a 401, the login token is missing — check whether Authorization: Bearer (your token) survives in the Repeater request’s headers. You can copy the token from the Application tab’s cookies/storage.

3-6. The Admin Page — Hidden or Blocked?

While logged in as an ordinary account, type http://localhost:3000/#/administration directly into the address bar (screen example):

403 Forbidden  — "You are not authorized..."

Or, depending on the version, the page opens but the data APIs (/rest/user/...) return 403. Either way, the observation point is the same — hiding the menu in the frontend and the server blocking the request are separate things. Real protection must always be confirmed by the server-side response (403/401).

3-7. Attempting to Edit Someone Else’s Review

A product page’s reviews are edited with PUT requests. Catch the review-writing request in the Network tab, send it to Repeater, change the review ID or author in the body to someone else’s, and press Send. If the server overwrites it without verifying the author, another access-control challenge is solved (screen example — confirm the success and challenge name yourself).

Why: reading (GET) is not the only IDOR. If writing (PUT/DELETE) lacks verification, you can delete and edit others’ posts — far greater damage. From today on, at every numbered request, suspect together: "can I also edit and delete?"


4. Missions & Exercises

Mission — Prove It in My Lab, Find It in the Wargame

  1. Complete and run the 3-1 idor_lab.py, and capture the screen where /order/2 reads with no login
  2. Confirm that accessing someone else’s order is blocked with a 403 in the /v2/ defended version
  3. Find 2+ APIs with exposed ID numbers in Juice Shop via the Network tab and make a list
  4. Succeed at IDOR on one of them by swapping the number, and record the request and response in a write-up
  5. Reach a cumulative 30 on the score board (15 added to Step 148’s 15)

Exercises

Exercise 1. Explain the difference between authentication and authorization in one sentence each, in technical terms, without the hotel analogy.

Exercise 2. Explain why someone else’s order was readable with no login in the 3-2 experiment, from the perspective of the "missing code" in the order() function.

Exercise 3. What is the difference between HTTP 401 and 403, and which check is missing when each appears?

Exercise 4. Explain why deleting the admin menu button in the frontend is not a security measure.


5. Model Answers & Completion Criteria

Mission Model Answer

Items 1–2 are exactly the 3-2 and 3-3 measurements. The key contrast screen:

Vulnerable: GET /order/2    → 200  {"owner":"bob", ...}     ← exposed with no login
Defended:   GET /v2/order/2 → 403  {"error":"forbidden: not your order"}

An example list for item 3 (screen example — paths may differ by version): /rest/basket/N (basket), /api/Feedbacks/N (reviews), /rest/user/... (member-info family). In item 4’s write-up, write "the original request / the changed number / the response status / the other person’s data in the response / what a defense would have needed." For item 5, check the count on the score board (/#/score-board).

How to verify: ① in the vulnerable version, did the response actually contain someone else’s data (200 + their data)? ② in the defended version, is the same request a 403? ③ for the Juice Shop success, did a challenge check appear on the score board?

Exercise Answers

Answer 1. Authentication is the procedure that verifies the requester’s identity (login, token verification); authorization is the procedure that checks whether the verified identity holds permission for the given resource and action (owner check, role check).

Answer 2. Because order() has only code that pulls data by number, and no code comparing the requester against the data’s owner. The vulnerability was not added bad code but a missing check — "absent code."

Answer 3. 401 is an authentication failure (no identity or an invalid token); 403 is an authorization failure (identity confirmed but no permission). In the 3-3 measurement, the header-less request split exactly into 401, and alice accessing someone else’s order into 403.

Answer 4. Deleting a button is a change that happens only on the screen (the client), so it’s bypassed by typing the address directly or sending the request through Burp. Security decisions must be made on the server, not the client, and only the server’s 403 response is a real block.

Completion Criteria Checklist

  • [ ] I can state the difference between authentication and authorization in one sentence each
  • [ ] I can explain IDOR as "a missing owner check"
  • [ ] I read someone else’s order by swapping the number on a local Flask server
  • [ ] I confirmed 401/403 appear appropriately per situation in the defended version
  • [ ] I can edit a request’s ID and re-send it with Burp Repeater
  • [ ] I confirmed "hidden ≠ blocked" with the admin page case
  • [ ] Mission: one IDOR success write-up + cumulative 30 on the score board

6. Common Pitfalls & Fixes

Wall 1. The server is running but requests get "connection refused"

Symptom (measured-family message):

urllib.error.URLError: <urlopen error [WinError 10061] No connection could be made because the target machine actively refused it>

Cause: the server process is off, or you closed the terminal it runs in. A Flask server lives attached to its terminal.
Fix: check that Running on http://127.0.0.1:5491 is alive in the server terminal, and always send requests from a different terminal.

Wall 2. Non-ASCII text comes out garbled like \uae30\uc2dd

Symptom (measured 2026-09-09):

'{"id":1,"item":"\\uae30\\uacc4\\uc2dd ..."}'

Cause: Flask’s JSON responses emit non-ASCII as Unicode escapes by default. It’s not broken — just a different notation for the same characters.
Fix: add app.json.ensure_ascii = False and the text comes out as-is (included in the 3-1 code). The data itself is identical either way.

Wall 3. I sent it via Repeater and got 401 Unauthorized

Cause: the request is missing the login token (the Authorization: Bearer ... header). Most of Juice Shop’s /rest/ APIs require a login.
Fix: sending a request captured while logged in to Repeater brings the headers along. If you deleted the header, copy the token again from the Application tab and put it back.

Wall 4. I changed the number but still see only my own

Cause: that API has an owner check — you found a defended API, so it’s not a failure but an observation.
Fix: move to a different number-exposed API. Juice Shop is a deliberately vulnerable app, so a path with a missing check definitely exists. The score board’s hints (💡) narrow down the candidates.

Wall 5. An error says the port is already in use

Symptom (measured-family message):

OSError: [WinError 10048] Only one usage of each socket address (protocol/network address/port) is normally permitted

Cause: the idor_lab.py you started earlier is still alive.
Fix: kill it with Ctrl+C in that terminal, or change the port in the code to something like 5492.


7. Summary

Today’s Concepts

Concept One-line explanation
Authentication Confirms "who you are" — login, token verification
Authorization Confirms "whether you may" — owner and role checks
IDOR A vulnerability where unverified object references like numbers open others’ data
Horizontal privilege escalation Viewing the data of same-rank users (admin takeover is vertical)
401 / 403 Authentication failure / authorization failure
Hidden ≠ blocked A button deleted in the frontend is not security — the server response is real

Today’s Tools & Commands

Tool/command What it does
python idor_lab.py Run the local vulnerable server
urllib.request.urlopen(...) Send HTTP requests with Python
Developer tools Network tab Find APIs with exposed numbers
Burp Send to RepeaterSend Edit a request’s ID and re-send
Authorization: Bearer header Keep the login token
if o["owner"] != user: 403 The core one line of IDOR defense

An Instinct More Important Than Commands

When a number is visible in an address, that number is a manipulation target. From today, numbers in API addresses will look different — not /basket/6 but /basket/{what if I change this?}. And remember the vulnerability’s essence: most access-control flaws are "missing code," an absent check. The attacker’s eye looks not for "what should I send" but "what did they forget to check." When you return to defense, the same sentence becomes the defense — one line of owner verification.


Once every box is checked, Step 149 is complete. Click the checkbox in the sidebar to save your progress.