Two versions of the same e-commerce store — one vulnerable, one fixed. Attack both and see exactly what secure code does differently.
Both apps are identical ShopEasy stores with the same data, pages and features. The only difference is the code underneath.
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.
secret123)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.
Both apps run locally in Docker simultaneously. No accounts, no cloud, no fees.
Docker runs both apps in isolated containers. Install for your operating system.
Verify: docker --version
Pull the repo to your machine.
One command starts both simultaneously.
Open in two browser tabs side by side.
Stops both containers cleanly.
cd shopeasy-vulnerable && docker compose up --build -d starts only the vulnerable app on port 5000. Same for shopeasy-secure on port 5001.
Same accounts exist on both apps.
All five tools are free and open-source. Kali Linux users have most of them already.
AttributeError: module 'jwt' has no attribute 'decode', fix with: pip3 install --upgrade --force-reinstall PyJWT --break-system-packages
All four attacks work on this version. Work through them in order — each one builds on the previous.
Save this as ~/api-wordlist.txt.
Change /orders/1 to /orders/3. Order #3 belongs to Bob.
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.
/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.
Run every attack again — this time against the fixed version. See exactly how each fix responds.
Only the port changes — everything else is identical.
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.
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.
Returns Alice's order normally — the fix only blocks access to other users' orders, not your own.
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.
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.
Use the same forged token you created against the vulnerable app (signed with secret123) and send it to the secure app:
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.
No matter what value you pass, you always get Alice's inbox:
All five return the same result — Alice's own shipping notification. The parameter has no effect.
The same four techniques. Two different outcomes.