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.
Vulnerable vs Secure
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.
- ✗ 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
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
Lab setup
Both apps run locally in Docker simultaneously. No accounts, no cloud, no fees.
Step 1 — Install Docker
Docker runs both apps in isolated containers. Install for your operating system.
Verify: docker --version
Step 2 — Clone and start both apps
Get the files
Pull the repo to your machine.
Launch both apps
One command starts both simultaneously.
Open both stores
Open in two browser tabs side by side.
Shut down when done
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.
Test accounts
Same accounts exist on both apps.
What you'll use
All five tools are free and open-source. Kali Linux users have most of them already.
Install all at once (Kali / Ubuntu)
AttributeError: module 'jwt' has no attribute 'decode', fix with: pip3 install --upgrade --force-reinstall PyJWT --break-system-packages
Attacking the vulnerable app
localhost:5000All four attacks work on this version. Work through them in order — each one builds on the previous.
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.
Create the wordlist
Save this as ~/api-wordlist.txt.
Run the fuzzer against the vulnerable app
Investigate the hits
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 (❌).
Log in as Alice and save token
Access Alice's own order — expected
The attack — access Bob's order
Change /orders/1 to /orders/3. Order #3 belongs to Bob.
Enumerate all orders
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.
Get a token and decode it
Crack the signing secret
Forge an admin token
Use the forged token
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.
Read Alice's own messages
The attack — change user_id to 4
Enumerate all inboxes
Attacking the secure app
localhost:5001Run every attack again — this time against the fixed version. See exactly how each fix responds.
Run the exact same fuzzer command
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.
Try to access the debug endpoint directly
Try to access admin users without a token
Get Alice's token from the secure app
Try to access Bob's order
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.
Alice's own order still works
Returns Alice's order normally — the fix only blocks access to other users' orders, not your own.
Get a token and inspect the payload
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.
Try to crack the secret
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.
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:
Try the attack — pass user_id=4 in the query
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.
Confirm with any user_id value
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.
All attacks — both apps
The same four techniques. Two different outcomes.