A real report, start to finish
What a deep scan actually produces — 35 findings in OWASP Juice Shop, unedited.
This is a real, unedited deep scan of juice-shop/juice-shop
— OWASP’s deliberately vulnerable training app, 13,750 stars, MIT.
Commit 1618a611b173, scanned 2026-08-31.
Its flaws are put there on purpose, which is why it is safe to publish this. Nothing was added, removed or reworded to make the output look better.
Clone it and run npx launchprep yourself — the free half of this
is reproducible on your own machine in a minute.
Recycle record lookup has no ownership check
routes/recycles.ts:12 · AUTHZ-003
Any authenticated (or in some deployments unauthenticated) visitor can pass an arbitrary numeric id in the URL and receive another customer's recycling request, which includes their address reference and pickup details. The query filters only on id, never on the requester's UserId.
Fix Add a where clause that also requires UserId to match the logged-in user's id (from the verified JWT), and return 403/404 on mismatch.
Basket contents returned for any basket id without ownership check
routes/basket.ts:17 · AUTHZ-003
retrieveBasket loads a basket purely by the :id route parameter; the only place the owning user is checked is inside a challenge-detection callback that only sets a flag for the training challenge, it never blocks the response. Any logged-in customer can view the full cart contents (products, quantities) of any other customer by changing the basket id in the URL.
Fix Before returning the basket, verify that the basket's UserId equals the id of the currently authenticated user (as already computed for the challenge check) and return 403 if it does not.
Wallet top-up amount is never validated
routes/wallet.ts:27 · DATA-006
Any logged-in customer can call the wallet top-up endpoint with an arbitrary 'balance' value (any number, including huge or negative amounts) after only proving they own a stored card. Nothing checks that the amount matches a real payment, so a customer's wallet balance can be inflated indefinitely for free.
Fix Validate req.body.balance server-side against an allow-listed set of top-up amounts (or verify it against an actual payment-provider charge amount) before calling WalletModel.increment.
No backup plan for the only persistent data store
infrastructure/docker-compose.yml:77 · DATA-012
The juice-shop-data volume (holding the SQLite database) and mongo-data volume are declared with 'driver: local' and nothing else. If the host disk or container is lost, all user accounts, orders, wallets, and addresses are gone permanently, and there is no documented or automated way to restore them.
Fix Add a scheduled backup step (e.g. a sidecar/cron job dumping the SQLite file and Mongo collections to external storage, or use AWS Backup on the Terraform EFS volume) and document/test a restore procedure.
Unrestricted server-side fetch of user-supplied URL
routes/profileImageUrlUpload.ts:24 · API-014
Any authenticated user (registration is open) can submit an arbitrary URL and the server will fetch it, allowing the app server to be used to probe or reach internal-only endpoints (e.g. cloud metadata services, internal admin panels) that are not reachable from the public internet directly. The result is even written back into the user's own profile image or error message, giving partial response feedback.
Fix Resolve the URL's host before fetching and reject requests to private/link-local IP ranges (RFC1918, 169.254.0.0/16, etc.) and non-HTTP(S) schemes, or restrict image fetching to an explicit allowlist of trusted domains.
Memory record UserId taken from request body without validation
routes/memory.ts:15 · DATA-006
The 'Add Memory' endpoint stores whatever UserId value is sent in the request body as the owner of the new memory row, without checking it against the authenticated user or even that it is a valid integer. A user could attach a photo/caption to someone else's account.
Fix Derive UserId from the authenticated session (as done elsewhere via security.authenticatedUsers.from(req)) instead of trusting req.body.UserId, and validate it is a number that exists.
Profile pictures are saved under a public, unauthenticated static path
routes/profileImageFileUpload.ts:40 · DATA-013
Uploaded profile images are written to frontend/dist/frontend/assets/public/images/uploads/<userId>.<ext>, a directory served statically to anyone with no login check. Since the filename is just the sequential user ID, any visitor can enumerate other users' profile photos simply by requesting /assets/public/images/uploads/1.png, /2.png, etc.
Fix Store uploaded images outside the public static root and serve them through an authenticated route that checks the requester is allowed to view that user's image, or use unguessable per-file tokens.
Deployments use a mutable 'latest' image tag with no rollback mechanism
infrastructure/terraform/variables.tf:64 · DEP-007
The container_tag variable defaults to 'latest' and the ECS task definition always references this same tag. If a bad release is deployed, the previous working image has already been overwritten in the registry, so there is no way to redeploy the last-known-good version without rebuilding it from source.
Fix Tag images with immutable version identifiers (e.g. git SHA or semver) and keep the ECS task definition parameterized on that tag so a previous revision can be redeployed directly as a rollback.
Auth token library pinned to an ancient, abandoned version
package.json:129 · DEP-012
lib/insecurity.ts uses this exact 'jsonwebtoken' package to sign and verify every user's session token. Version 0.4.0 predates years of security fixes to the jsonwebtoken library (algorithm-confusion and verification issues in that era), so any customer's authentication token handling relies on a dependency that no longer receives security patches.
Fix Upgrade to a current, maintained major version of jsonwebtoken (and express-jwt) and adjust the signing/verification calls to the modern API.
Production data volume has no backup policy and no restore drill
infrastructure/terraform/networking.tf:249 · DEP-018
The aws_efs_file_system resource holding the application's SQLite database is created with no aws_efs_backup_policy and no snapshot/backup automation. If the EFS filesystem is corrupted or accidentally deleted, all user, order, and product data is gone with no way to restore it, and there is no evidence anyone has ever tested a restore.
Fix Attach an aws_efs_backup_policy (or AWS Backup plan) to the EFS filesystem and document/periodically run a restore drill that recovers data into a test environment to prove backups are usable.
SVG profile images are accepted and served same-origin
routes/profileImageFileUpload.ts:29 · UP-004
The upload handler only checks that the detected MIME type starts with 'image', which is true for image/svg+xml. An SVG can embed <script> or event-handler XSS payloads. Because the file is saved under frontend/dist/frontend/assets/public/images/uploads/ and served from the app's own origin, visiting the resulting profile image URL executes the attacker's script in the context of the Juice Shop domain, letting an attacker steal other users' session tokens.
Fix Reject SVG uploads (allowlist only raster formats like png/jpg/gif/webp) or sanitize SVG content and serve uploaded images from a separate, script-disabled origin/CDN with a strict Content-Security-Policy and Content-Disposition: attachment.
URL-based profile image import also allows SVG, served same-origin
routes/profileImageUrlUpload.ts:28 · UP-004
When a user submits an imageUrl, the extension is chosen from an allowlist that includes 'svg', and the fetched file is written unmodified into the same public uploads directory. Any attacker-hosted SVG with a script payload becomes same-origin stored XSS for the victim who set it as their own profile picture (or whoever later views it).
Fix Remove 'svg' from the allowed extension list for imported profile images, or run any accepted SVG through a strict sanitizer before storage.
Zip extraction has no size or entry-count bounds
routes/fileUpload.ts:28 · UP-012
extractZipBuffer opens and iterates every entry of an uploaded zip and writes each one to disk with no cap on the number of files, their individual size, or the total decompressed size. A crafted zip bomb or an archive with millions of tiny entries can exhaust disk space or file handles on the server, affecting all users of the shop.
Fix Before extracting, enforce limits on total entry count, per-entry uncompressed size, and total uncompressed size (reject or abort extraction if exceeded), and validate that resolved paths stay within the target directory.
Path traversal guard on extracted entries is weak
routes/fileUpload.ts:34 · UP-012
The only check before writing an extracted file is that the resolved absolute path 'includes' the current working directory path, which does not reliably prevent traversal to sibling directories still under the cwd tree, and the code is explicitly designed to let a crafted zip write to ftp/legal.md for the fileWriteChallenge. Combined with no bound on extraction, an attacker-supplied archive can write many files anywhere reachable under the working directory.
Fix Resolve each entry path with path.resolve and verify with path.relative that it does not start with '..' relative to the intended target directory, rejecting any entry that would escape it.
No way to revoke an issued session/token
lib/insecurity.ts:188 · INC-001
If a user's JWT is stolen (e.g. via XSS) or a password is changed, the token remains fully valid for up to 6 hours because any request bearing a signature-valid JWT that isn't in the in-memory map gets silently re-added and trusted here. There is no server-side blacklist, token version, or 'invalidate all sessions' capability for a user or admin to use during an incident.
Fix Add a persisted token-version or session-id claim per user that is bumped on password change/logout/'log out everywhere', and reject tokens whose version does not match the current value stored on the user record.
Password change does not revoke other active sessions
routes/changePassword.ts:47 · INC-001
After a user changes their password, the JWT they were previously issued (and any other active sessions/tokens for that account) keep working unchanged, so an attacker who already holds a stolen token is not locked out by the victim's remediation attempt.
Fix On password change, increment a per-user token version/generation counter and check it on every authenticated request so older tokens are rejected immediately.
No runtime toggle for the remote-image-fetch feature
routes/profileImageUrlUpload.ts:24 · INC-002
This endpoint lets any logged-in user make the server fetch an arbitrary attacker-supplied URL. If this is abused in production (e.g. to probe internal infrastructure), there is no configuration switch or feature flag to disable the endpoint without a code change and redeploy.
Fix Gate this handler behind a config flag (e.g. features.allowRemoteProfileImage) that can be flipped via environment/config reload, and add an allowlist or deny-private-IP check regardless.
Wallet balance can be increased by an arbitrary attacker-controlled amount with no monitoring
routes/wallet.ts:27 · INC-006
addWalletBalance only checks that the supplied card belongs to the user; the amount added to the wallet comes directly from req.body.balance with no upper bound, and no log entry or alert is generated for large or repeated top-ups. A user (or automated script) could inflate their balance repeatedly and nobody would notice until someone happened to look.
Fix Enforce a sane maximum per-transaction and per-day top-up amount server-side, and emit a structured log/alert event whenever a top-up exceeds a threshold so finance/security can be notified in near real time.
Logging out does not revoke the session token on the server
lib/insecurity.ts:68 · AUTH-007
authenticatedUsers only exposes put/get/tokenOf/from/updateFrom — there is no delete/invalidate method, so nothing in the codebase can actually kill a session server-side. A stolen or 'logged out' token stays cryptographically valid (RS256, 6h expiry) and keeps working against every endpoint that checks it, so a user who logs out on a shared computer has not actually ended their session for anyone with the old token/cookie.
Fix Add a token blacklist/revocation store (or move to short-lived tokens plus a rotating refresh token) and clear the entry from authenticatedUsers.tokenMap on logout.
Any request re-inserts a stale JWT into the authenticated-users map
lib/insecurity.ts:121 · AUTH-007
updateAuthenticatedUsers verifies the JWT signature and, if valid, calls authenticatedUsers.put(token, decoded) again regardless of whether that token had ever been removed. Even if a future logout handler deleted the entry, presenting the same still-unexpired JWT (e.g. replayed from browser history or a proxy log) restores full access for up to 6 hours.
Fix Do not repopulate the authenticated-users map purely from JWT signature validity; check a persisted revocation list before treating a token as live again.
Username change accepts cookie auth with no CSRF protection
routes/updateUserProfile.ts:38 · API-007
An attacker can host a page that auto-submits a form to this endpoint; because the browser will send the victim's `token` cookie automatically, the victim's username gets changed without their consent. The only origin/referer check present (lines 31-36) exists solely to detect and flag the CSRF challenge as solved — it does not block or reject the request.
Fix Require a per-session CSRF token (e.g. via csurf or a double-submit cookie) on all cookie-authenticated state-changing routes, or stop accepting the JWT via cookie for mutations and require the Authorization header only.
Profile image upload via URL is cookie-authenticated with no CSRF check
routes/profileImageUrlUpload.ts:22 · API-007
Like the username-change endpoint, this state-changing action (fetching a remote image and overwriting the user's profile image) is triggered purely by the `token` cookie with no CSRF token, letting a third-party site silently force a logged-in victim's browser to invoke it.
Fix Add CSRF token validation for this route or move to header-only JWT authentication for all mutating endpoints.
No automated retention/purge policy for personal data
models/privacyRequests.ts:24 · LIFE-001
Addresses, cards, security answers, orders and feedback are stored indefinitely. The only privacy mechanism visible is a boolean 'deletionRequested' flag on PrivacyRequestModel; nothing in the codebase schedules deletion, anonymization, or expiry of this data even after a request is recorded.
Fix Add a scheduled job that processes PrivacyRequestModel entries and deletes/anonymizes the associated user's records across all tables and Mongo collections, and define explicit retention windows for orders, feedback and logs.
Blocking busy-wait loop freezes the whole server
routes/showProductReviews.ts:23 · DATA-011
The global.sleep helper spins in a synchronous while-loop for up to 2 seconds to simulate MongoDB's blocking behavior. Because Node.js is single-threaded, every other request being served by the process stalls for the same duration while this runs, so one slow review-timing request can freeze the entire shop for all concurrent visitors.
Fix Replace the busy-wait with a non-blocking delay (e.g. setTimeout/await) or move the timing-sensitive logic to a worker thread so it cannot stall the main event loop.
Account enumeration via security-question lookup
routes/securityQuestion.ts:21 · RU-018
Anyone can submit an arbitrary email address to /rest/user/security-question and learn from the response shape (a security question object vs. an empty object) whether that email belongs to a registered account. There is no rate limiting or throttling on this endpoint, so an attacker can enumerate the full customer email list at will.
Fix Return the same generic response (e.g. a fixed placeholder question or a 404) regardless of whether the email is registered, and add rate limiting/CAPTCHA to the endpoint.
Unlimited memory image uploads per user
routes/memory.ts:17 · UP-010
Any authenticated user can call this endpoint repeatedly to upload arbitrarily many images with no count or total-size limit, letting a single account fill server disk space indefinitely.
Fix Track cumulative storage used per UserId (count and/or bytes) and reject new uploads once a configured quota is exceeded.
Complaint file attachments have no per-user limit
models/complaint.ts:16 · UP-010
The Complaint model stores an arbitrary file path per complaint with no cap on how many complaints (and attached files) one user can create, allowing storage exhaustion by a single account submitting many complaints.
Fix Enforce a maximum number of complaints or total attachment size per user, checked before accepting a new upload.
Failed logins are silently rejected with no tracking or alerting
routes/login.ts:50 · INC-007
Every incorrect login attempt returns a 401 with no counter, log line, or rate limit tied to the email or IP, so a large-scale password-spraying or brute-force attack against customer accounts produces no signal an operator could act on. (The project's own code-fix examples for a different endpoint, resetPasswordMortyChallenge, show that rate limiting is treated as an optional 'fix', not something enabled by default.)
Fix Add rate limiting per account/IP on /rest/user/login and emit a metric/log event on repeated failures so an auth-failure-spike alert can be built on top of it.
Users can register with any email address, verified or not
models/user.ts:33 · AUTH-005
The User model stores email, password and role with no verified/confirmed flag, and no companion model (e.g. an email-verification-token table) exists anywhere in models/index.ts. Anyone can sign up using someone else's real email address (e.g. a coworker's or a public figure's) and the account is immediately usable, no confirmation link required.
Fix Add an emailVerified flag to the User model, send a confirmation link on registration, and block login (or at least mark the account) until the link is clicked.
Login endpoint has no throttling or breach/anomaly checks
routes/login.ts:36 · AUTH-009
The login handler runs the password query and returns 401 on mismatch with no attempt counter, no CAPTCHA, no IP/device throttling, and no check against known-breached password lists. An attacker can script unlimited password guesses or credential-stuffing attempts against any account with no server-side friction.
Fix Add rate limiting (per-IP and per-account) and a lockout/backoff mechanism on /rest/user/login, and consider checking new passwords against a breached-password list at registration/change time (a related check already exists as a coding-challenge fix for the password-reset endpoint, but nothing equivalent protects login itself).
Hardcoded, non-rotatable JWT signing key
lib/insecurity.ts:21 · SEC-007
Every user session token is signed with one hardcoded private key baked into the source file, and the matching public key is read from a single static file (encryptionkeys/jwt.pub). There is no key identifier in the token, no support for a second/next key, and no process shown for rotating this key if it were ever exposed — doing so today would require a code change and redeploy, invalidating every logged-in user with no graceful transition.
Fix Move the signing key out of source into a secrets manager, add a 'kid' header and support validating against a small set of current+previous public keys so the private key can be rotated without a hard cutover, and document the rotation runbook.
Failed login attempts are not logged
routes/login.ts:47 · OBS-002
When a customer or attacker submits a wrong email/password, the server just responds 401 'Invalid email or password.' with no call to the logger. There is no way for an operator to see a spike of failed logins that would indicate brute-forcing or credential stuffing against real customer accounts.
Fix Add a logger.warn(...) call (with source IP, not the password) on every failed authentication attempt in the login handler.
Authorization denials are silently returned to the caller only
lib/insecurity.ts:146 · OBS-002
isAccounting() responds with a 403 'Malicious activity detected' when a non-accounting user hits an accounting-only endpoint, but nothing is logged server-side, so repeated privilege-escalation attempts against real user accounts leave no trace for an operator to notice.
Fix Log denied authorization attempts (user id, route, timestamp) via the existing winston logger before returning the 403.
Cross-account resource access attempts are not logged
routes/payment.ts:58 · OBS-002
getPaymentMethodById/delPaymentMethodById return 'Malicious activity detected' when a UserId/id combination doesn't match, but this suspicious event (someone probing another customer's card records) is never written to a log, so an operator cannot detect an ongoing enumeration attack.
Fix Log these 400 'Malicious activity detected' responses with the requesting user id and target id so repeated probing is visible in logs/alerts.
No alerting configured for service health or downtime
infrastructure/terraform/main.tf:128 · OBS-004
The ALB target group has a health check and CloudWatch Container Insights is enabled on the ECS cluster, but there is no aws_cloudwatch_metric_alarm, SNS topic, or similar resource anywhere in the Terraform to notify anyone when the service becomes unhealthy or task count drops to zero. If the app goes down outside business hours, ECS will keep retrying but no human is notified, so an outage could run for hours before anyone notices.
Fix Add a CloudWatch alarm on ECS service RunningTaskCount / ALB UnHealthyHostCount wired to an SNS topic (email/Slack/PagerDuty) so downtime triggers a page instead of silent auto-restarts.
What the scan could and could not settle
69 deep checks applied to this app. Of those, 25 found something, 4 came back clean, 8 did not apply to what it is, and 32 could not be settled from the code that was read.
That last number is reported rather than hidden. A check that could not reach an answer has not passed, and a report that quietly counts it as one is telling you you are safe when nobody looked.
This repository is larger than one scan holds: 159 of 427 ranked files were read, most security-relevant first. The next scan of the same project starts with the rest.
Run the free checks on your own project, on your machine, unlimited:
npx launchprep
The deep scan above is the paid half — $29 once, five scans.
Run every check that applies to your project, on your machine, free and unlimited:
npx launchprep