Real audits.
Redacted case studies.
Chained-attack reasoning · CVSS v3.1 · OWASP + CWE. Client names and identifiers stripped; the vulnerability classes and attack chains stay intact.
Two findings that combine into a worse one get upgraded — not graded in isolation.
Numeric score 0.0–10.0 + vector string, so triage matches industry tooling.
Categories mapped to CWE IDs; chains mapped to OWASP Top 10 root causes.
Every finding ships a remediation the AI applies inline — not a "be careful."
Case studies
Auth bypass via a single client-controlled flag
Case study #1 · 2024
A merchant-portal admin endpoint exposed every customer's PII and API secrets when a single boolean in the request body was flipped to true. The AI that scaffolded the endpoint had added the flag as a "local development mode" shortcut and shipped it to production.
Auth bypass via a single client-controlled flag
Case study #1 · 2024true. The AI that scaffolded the endpoint had added the flag as a "local development mode" shortcut and shipped it to production.- Industry
- Logistics / e-commerce backend (redacted)
- Built & deployed on
- Lovable (vibe-coded full-stack SaaS, public app at
<founder-slug>.lovable.app) - Stack signature
- Node.js + Express, JWT bearer auth, REST JSON
- Caught by
- Save-time scan — first save of the route file
The scenario
A founder building a merchant portal on Lovable needed an endpoint to return the currently-logged-in merchant's config (display name, contact info, carrier credentials). They wanted the handler to support a dev-mode escape hatch so staging integrations could be tested without going through full auth. The AI obliged — and shipped a single line that turned out to be the entire vulnerability. Lovable's generated route went live on the customer's Vercel-hosted deployment within an hour of the prompt; the bug was attacker-reachable from minute one.
The vulnerable code (AI-generated, reconstructed)
This is the shape of the code that produced the finding — names changed, but the antipattern is identical to what we caught.
// POST /api/merchant-config
// Returns the requested merchant's config + carrier credentials.
app.post("/api/merchant-config", async (req, res) => {
const { merchant, token, localModeEnabled } = req.body.params;
// Local dev shortcut — skip auth so we can test without minting a JWT.
if (localModeEnabled) {
const cfg = await db.merchants.findOne({ id: merchant });
return res.json(cfg); // ← returns email, phone, API secrets
}
// Normal flow: verify the bearer + check that the caller owns this merchant.
const claims = await verifyToken(token);
if (!claims.merchantIds.includes(merchant)) {
return res.status(403).json({ error: "forbidden" });
}
const cfg = await db.merchants.findOne({ id: merchant });
res.json(cfg);
});
Why it's a vulnerability
localModeEnabled comes from the request body. Any attacker can set it. Once true, the entire ownership check is skipped — the endpoint becomes "give me any merchant's config, no questions asked." Pair that with an easy-to-enumerate merchant identifier (logos cached by archive.org, public marketplace listings) and a single curl loop dumps every merchant's:
- Email address + phone number — direct PII
- Webhook URL the merchant configured — useful for SSRF pivots
- Carrier API secret — the credential that authenticates shipping-label generation, refund automation, address-validation calls. Stealing this lets the attacker print labels and run refunds on the merchant's account.
Why an AI wrote this
The AI was asked "let me bypass auth in local dev." A human security engineer would either refuse and suggest a separate /dev/* mount in the route table gated by a hostname or env check, or bind the bypass to process.env.NODE_ENV === "development", server-side only, with no client input involved. The AI took the prompt at face value, named the toggle clearly, and wrote it onto the request body where it's an attacker's plaything.
How Literal Security catches it
On the save that introduced this code, our scanner returns a structured finding:
{
"decision": "fix-required",
"findings": [{
"severity": "high",
"category": "auth",
"line": 6,
"cwe": 602,
"cvss": 7.5,
"title": "Authentication bypass via client-controlled flag",
"evidence": "if (localModeEnabled) { ... return res.json(cfg); }",
"remediation": "Never gate auth-bypass on a value from req.body. Replace with a server-only check: `if (process.env.NODE_ENV === 'development' && req.hostname === 'localhost') { ... }` — or remove the bypass entirely and use a separate local-only route file that's not mounted in production."
}]
}
In agent-mode tools (Cursor Composer, Cline, Roo, Windsurf Cascade) the AI reads this finding from the Problems panel on its next turn and rewrites the handler before the developer reviews. In single-turn / plain VS Code, the Quick Fix lightbulb opens the user's AI chat with the remediation pre-typed — one click, one save, fixed. On every active tier, the same finding can flow into AGENTS.md automatically (opt-in per workspace) so any AI tool reading the repo sees it on every subsequent prompt.
The fix (what shipped)
// POST /api/merchant-config — auth-bypass removed; bypass moved to a
// hostname-gated server-only flag if needed for local dev.
app.post("/api/merchant-config", async (req, res) => {
const { merchant, token } = req.body.params;
const claims = await verifyToken(token); // throws on invalid/expired
if (!claims.merchantIds.includes(merchant)) {
return res.status(403).json({ error: "forbidden" });
}
const cfg = await db.merchants.findOne({ id: merchant });
res.json(redactSecrets(cfg, claims.scope)); // defense in depth
});
The takeaway
Single boolean. One line of AI-generated convenience. Direct path to every customer's PII and carrier API secrets. The classic "AI gives you what you asked for" failure mode — the founder wanted a bypass, Lovable's AI wrote a bypass, the bypass shipped to the live deployment. We catch this class of code the moment it lands on disk, before it reaches commit. The bypass exists because no one in the loop thought about who else can set this flag. That's the job our scanner does on every save — on Lovable, on Bolt, on Cursor, on whatever's writing the code for you.
Authentication isn't authorization: IDOR that exposed every live + draft broadcast
Case study #2 · 2019
A video-streaming platform's broadcast-manifest endpoint took a numeric broadcastId from the URL path, verified the caller's bearer token was valid, and handed back the live HLS .m3u8 manifest — without ever checking that the broadcast actually belonged to the caller. Broadcast IDs were sequential. A single enumeration loop over a few thousand IDs returned dozens of currently-live streams from arbitrary users + the manifests of recently-ended broadcasts the owners had saved as private drafts.
Authentication isn't authorization: IDOR that exposed every live + draft broadcast
Case study #2 · 2019broadcastId from the URL path, verified the caller's bearer token was valid, and handed back the live HLS .m3u8 manifest — without ever checking that the broadcast actually belonged to the caller. Broadcast IDs were sequential. A single enumeration loop over a few thousand IDs returned dozens of currently-live streams from arbitrary users + the manifests of recently-ended broadcasts the owners had saved as private drafts.- Industry
- Video / live-streaming SaaS (redacted)
- Built & deployed on
- Replit Agent (full-stack vibe-coded app, public app at
<founder-slug>.replit.app) - Stack signature
- Node.js + Express, HLS origin behind a 302-redirect, bearer-in-URL for player-shareability
- Caught by
- Save-time scan — flagged the moment the handler landed in
routes/player-api.js
The scenario
A founder building a live-streaming product on Replit Agent needed a route that serves the HLS .m3u8 manifest for a broadcast — used by both their in-app player and a companion producer app. The AI was given the usual brief — implement a fetch-resource-by-ID endpoint with token validation and proper error codes — and produced something that reads as careful: it verified the bearer, it handled 401 and 404 cases cleanly, and it redirected to a CDN-hosted origin. What it never asked was the one question that mattered: does this broadcast actually belong to the user holding the token?
The vulnerable code (reconstructed)
Names changed; antipattern is identical to what we caught.
// routes/player-api.js
// GET /owner/auth/:token/accounts/:accountId/events/:eventId/broadcasts/:broadcastId.secure.m3u8
//
// Returns the HLS manifest for a broadcast. The bearer lives in the URL path
// (not a header) so the manifest URL is shareable inside HTML5 video tags
// where setting custom headers isn't possible.
app.get(
"/owner/auth/:token/accounts/:accountId/events/:eventId/broadcasts/:broadcastId.secure.m3u8",
async (req, res) => {
const { token, broadcastId } = req.params;
// 1. Verify the bearer is valid + unexpired.
const session = await verifyToken(token);
if (!session) return res.status(401).send("unauthorized");
// 2. Resolve the broadcast.
const broadcast = await db.broadcasts.findById(broadcastId);
if (!broadcast) return res.status(404).send("not found");
// 3. Redirect to the origin-hosted manifest.
res.redirect(302, broadcast.hlsManifestUrl);
},
);
Why it's a vulnerability
Four failures stack to turn a routine REST handler into a private-content firehose:
- Token verified, ownership ignored. The handler proves the caller has a valid session — but not that the requested
broadcastIdbelongs to that session. Authentication ✓, authorization ✗. Textbook CWE-639 / IDOR. - URL path looks ownership-aware but isn't enforced. The route template reads like the server checks the broadcast belongs to that account inside that event. It doesn't — the handler shortcuts to
findById(broadcastId)and ignores the surrounding path segments. Easy to miss in code review because the route LOOKS scoped. - Broadcast IDs are sequential. Enumerable in seconds. A single intruder run over a few thousand consecutive IDs surfaces every concurrently-live broadcast — no luck required.
- HLS manifests don't terminate when the broadcast ends. The
.m3u8file points to segment URLs. Once an attacker has fetched and saved the manifest, they can keep reading segments as long as the origin keeps serving them — content the owner later saved as a private draft remains exfiltratable.
Why an AI wrote this
The AI's mental model for "fetch resource by ID with token validation" is the canonical CRUD shape it has seen ten thousand times in its training data: validate the bearer, look up the resource by primary key, return it. The missing concept is "resource ownership" — being authenticated as user A does NOT entitle you to read resource B owned by user C. Junior engineers miss this constantly; AI tools that pattern-match against generic REST examples miss it by default. The route template's path scoping makes it visually look like ownership is being checked, which is exactly the kind of false-comfort signal that survives a quick code review.
How Literal Security catches it
{
"decision": "fix-required",
"findings": [{
"severity": "medium",
"category": "auth",
"line": 11,
"cwe": 639,
"cvss": 5.7,
"title": "IDOR: resource fetched by user-supplied ID without ownership check",
"evidence": "const broadcast = await db.broadcasts.findById(broadcastId);",
"remediation": "Authentication ≠ authorization. After verifying the token, confirm the resolved broadcast belongs to the caller before returning it. Either filter the query by ownership — `db.broadcasts.findOne({ id: broadcastId, accountId: session.accountId })` — or fetch then explicit-check: `if (broadcast.accountId !== session.accountId) return res.status(403).send('forbidden');`."
}]
}
The finding lands on the exact line where findById is called without an ownership predicate. In agent-mode AI tools the next-turn rewrite adds the ownership filter automatically; in plain VS Code the Quick Fix lightbulb opens the user's AI chat with the remediation pre-typed.
The fix (what shipped)
// routes/player-api.js — ownership-aware version.
app.get(
"/owner/auth/:token/accounts/:accountId/events/:eventId/broadcasts/:broadcastId.secure.m3u8",
async (req, res) => {
const { token, accountId, broadcastId } = req.params;
// 1. Verify the bearer.
const session = await verifyToken(token);
if (!session) return res.status(401).send("unauthorized");
// 2. Confirm the URL-claimed accountId is one the session can act on.
if (!session.accountIds.includes(accountId)) {
return res.status(403).send("forbidden");
}
// 3. Fetch the broadcast WITH ownership filter — don't trust caller IDs.
const broadcast = await db.broadcasts.findOne({
id: broadcastId,
accountId, // ← server-side ownership predicate
});
if (!broadcast) return res.status(404).send("not found");
res.redirect(302, broadcast.hlsManifestUrl);
},
);
The takeaway
Token-valid is not "you can have this." Every resource read needs an ownership predicate, every time, on the server. The handler above looked careful — bearer verified, error codes correct, 302 to a CDN — but the entire authorization model was missing. The class of bug exists because no one in the loop framed the right question: after this user is authenticated, what makes them allowed to read THIS specific resource? That's the question our scanner asks on every save, on every handler that resolves a resource by a user-supplied ID — whether the code came from Replit Agent, Lovable, Bolt, Cursor Composer, or any other AI in the writing seat.
Probe caught an admin endpoint exposed on prod that wasn't in source
Case study #3 · 2025
A SaaS dashboard built on Lovable shipped clean against the write-time scanner — every handler in source had the right ownership checks. But the founder ran a Manual Probe a week after deploy. The Probe discovered an /admin/users endpoint reachable on the live site that wasn't behind any auth at all. Root cause: a feature flag table in the database had admin_routes_enabled = true for the marketing demo, and the deployed app's middleware short-circuited the auth check when that flag was set. The flag flip happened outside source control — pure runtime config drift. The write-time scanner correctly had nothing to flag; the Probe is what saved them.
Probe caught an admin endpoint exposed on prod that wasn't in source
Case study #3 · 2025/admin/users endpoint reachable on the live site that wasn't behind any auth at all. Root cause: a feature flag table in the database had admin_routes_enabled = true for the marketing demo, and the deployed app's middleware short-circuited the auth check when that flag was set. The flag flip happened outside source control — pure runtime config drift. The write-time scanner correctly had nothing to flag; the Probe is what saved them.- Industry
- B2B analytics SaaS (redacted)
- Built & deployed on
- Lovable (public app at
<founder-slug>.lovable.app) - Stack signature
- Node.js + Express, Supabase, Vercel-style edge deploy
- Caught by
- Manual Probe — write-time scanner had no source-level bug to flag
The scenario
The founder had shipped a v1 with proper auth in every handler. They added a feature-flag system to toggle experimental routes on/off via a Supabase config table. The handler that gates admin routes looked like this in source:
// middleware/auth.js — looks fine in code review
app.use("/admin", async (req, res, next) => {
const flag = await db.flags.findOne({ key: "admin_routes_enabled" });
if (flag?.value === true) {
// "Demo mode" — temporarily relax auth so the marketing team
// can screenshot the admin dashboard without a real session.
return next();
}
const session = await verifyAdminSession(req);
if (!session) return res.status(401).send("unauthorized");
next();
});
Why the write-time scanner correctly missed it
From the scanner's perspective the code is conditionally-gated — it CAN'T tell from static analysis whether admin_routes_enabled is ever flipped to true in production. That depends on what's in the database, which the scanner doesn't see. Static analysis flags "auth bypass on a literal boolean" (we'd catch if (DEMO_MODE) return next();) but a database-backed flag check passes — it looks like legitimate feature-flag plumbing.
How the Probe caught it
The Probe runs as an unauthenticated attacker against the deployed site:
- Discovers routes by walking the sitemap, JS bundles, and common-path wordlist.
- Hits each route unauthenticated and records the response. An
/admin/usersresponse that returns 200 with a JSON list of user emails is a flag. - Returns a finding with the exact request that reproduced it, the response body's first 200 chars, and the remediation path: "Either remove the demo-mode flag entirely, or scope it to
NODE_ENV !== 'production'so it can never fire on the live site."
The fix
app.use("/admin", async (req, res, next) => {
// Demo-mode flag is dev-only by construction. Never trust a DB-stored
// toggle to disable auth on a live deployment.
if (process.env.NODE_ENV !== "production") {
const flag = await db.flags.findOne({ key: "admin_routes_enabled" });
if (flag?.value === true) return next();
}
const session = await verifyAdminSession(req);
if (!session) return res.status(401).send("unauthorized");
next();
});
The takeaway
Write-time scanning is necessary but not sufficient. Source code passes review; runtime configuration leaks through. The Probe catches what the Gate can't see by definition — anything that depends on production state (env vars, DB flags, CDN config, deployed middleware order). The Gate prevents bugs from landing on disk. The Probe finds bugs that landed via the live site's actual behavior. Both layers run on every project on the Startup and Business tiers, dashboard for Solo (1 probe/month).
Hardcoded admin override + MD5 password hashing — caught in one save
Case study #4 · 2026
A solo founder asked Cursor Composer to "write me a quick auth handler with an emergency admin login I can use if I lock myself out." Cursor wrote a login route with a hardcoded admin password fallback (if (password === "letmein-admin-2024")) AND used MD5 to hash the regular user passwords. Both got flagged in the same save, before the file landed on disk. The AI applied both fixes in the next chat turn and the founder never saw the broken version in their editor.
Hardcoded admin override + MD5 password hashing — caught in one save
Case study #4 · 2026if (password === "letmein-admin-2024")) AND used MD5 to hash the regular user passwords. Both got flagged in the same save, before the file landed on disk. The AI applied both fixes in the next chat turn and the founder never saw the broken version in their editor.- Industry
- Indie SaaS — task manager (redacted)
- Built & deployed on
- Vercel · public app at
<founder-slug>.vercel.app - Stack signature
- Next.js 14 (App Router) + Postgres, Cursor Composer writing the API routes
- Caught by
- Write-time scan via MCP —
securetool call on the AI's first save attempt
The scenario
The founder was setting up a fresh Next.js project. They asked Cursor: "Make me a /api/login route. Standard email+password. Also add an emergency admin login I can use if I get locked out of the dashboard." Cursor wrote what most LLMs trained on Stack Overflow would write: a literal admin password check, plus an MD5-hashed comparison for the regular flow because the password column was still VARCHAR(32) from a stale schema.
The code the AI tried to save
// app/api/login/route.ts — AI's first draft
import crypto from "node:crypto";
export async function POST(req: Request) {
const { email, password } = await req.json();
// Emergency admin login — bypass DB lookup if creds match.
if (email === "admin@app.com" && password === "letmein-admin-2024") {
return Response.json({ ok: true, role: "admin", token: signJwt({ role: "admin" }) });
}
// Normal user lookup.
const user = await db.users.findOne({ email });
if (!user) return Response.json({ ok: false }, { status: 401 });
const hashed = crypto.createHash("md5").update(password).digest("hex");
if (hashed !== user.password_md5) {
return Response.json({ ok: false }, { status: 401 });
}
return Response.json({ ok: true, role: "user", token: signJwt({ uid: user.id }) });
}
How the Gate caught it (one round-trip)
Cursor called secure({action: "scan_file", filename: "app/api/login/route.ts", content: ...}) before committing the write to disk. The scanner returned two findings in the same response:
{
"decision": "fix-required",
"findings": [
{
"severity": "critical",
"category": "auth",
"line": 7,
"cwe": 798,
"cvss": 9.8,
"title": "Anyone who reads this file can sign in as admin with one line of code.",
"evidence": "if (email === \"admin@app.com\" && password === \"letmein-admin-2024\")",
"remediation": "Hardcoded admin credentials in source ship with every deploy → end up in git history, the CI build cache, and the Vercel deployment logs. Delete this branch entirely. If you need a recovery path, use a signed time-limited token emailed to a verified admin address, not a literal password."
},
{
"severity": "high",
"category": "crypto",
"line": 17,
"cwe": 327,
"cvss": 7.5,
"title": "Passwords are hashed with MD5 — modern GPUs crack them in seconds.",
"evidence": "crypto.createHash(\"md5\").update(password)",
"remediation": "MD5 is broken for password storage (rainbow tables + GPU brute-force trivially recover plaintext). Replace with bcrypt or argon2: `await bcrypt.hash(password, 12)` on write, `await bcrypt.compare(password, user.password_hash)` on read. Schema: rename the column to `password_hash VARCHAR(60)` (bcrypt's fixed length)."
}
]
}
What Cursor wrote on the next turn (the file that actually landed)
// app/api/login/route.ts — after the Gate's findings, in-chat-turn rewrite
import bcrypt from "bcrypt";
export async function POST(req: Request) {
const { email, password } = await req.json();
// Recovery path is a signed magic link to a verified admin email,
// generated out-of-band — no literal credential in source.
const user = await db.users.findOne({ email });
if (!user) return Response.json({ ok: false }, { status: 401 });
if (!(await bcrypt.compare(password, user.password_hash))) {
return Response.json({ ok: false }, { status: 401 });
}
return Response.json({ ok: true, role: user.role, token: signJwt({ uid: user.id }) });
}
The takeaway
The founder never saw the broken code in their editor — Cursor's first save attempt hit the Gate, got two findings back, and rewrote the file in the same chat turn before the on-disk save completed. From the founder's perspective: they asked for a login handler, ten seconds later a bcrypt-hashed bcrypt-validated handler with no hardcoded creds appeared on disk. The "emergency admin login" disappeared from the conversation because the scanner's remediation explained why the entire pattern is wrong, and Cursor picked the secure alternative. This is the write-time loop in its happiest form — single round-trip, two chained findings, both auto-applied.
Want a finding from your codebase written up?
If Literal Security has surfaced something interesting on your project and you're up for a fully-redacted write-up, drop us a note. Your approval required before anything goes live.
Email hello@literalsec.com →