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
- Complete and run the 3-1
idor_lab.py, and capture the screen where/order/2reads with no login - Confirm that accessing someone else’s order is blocked with a 403 in the
/v2/defended version - Find 2+ APIs with exposed ID numbers in Juice Shop via the Network tab and make a list
- Succeed at IDOR on one of them by swapping the number, and record the request and response in a write-up
- 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 Repeater → Send |
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.