Examples from review runs on open-source and customer codebases. These are the findings that stop incidents before they become post-mortems.
SQL injection via string interpolation. User input from req.body.search flows directly into the WHERE clause with no parameterization. An attacker can terminate the LIKE expression and append arbitrary SQL — dropping tables, exfiltrating data, or bypassing auth. Severity: Critical.
// Parameterize all user input — never interpolate into query strings router.get('/products', async (req, res) => { const search = req.body.search ?? ''; + const result = await pool.query( + 'SELECT * FROM products WHERE name ILIKE $1', + [`%${search}%`] // value is bound — never parsed as SQL + ); res.json(result.rows); });
IDOR — Insecure Direct Object Reference. /api/invoices/:id returns invoice data without checking that req.user.id matches invoice.owner_id. Any authenticated user can read any other customer's invoice by iterating IDs. Severity: High.
router.get('/api/invoices/:id', requireAuth, async (req, res) => { const invoice = await db.invoices.findById(req.params.id); if (!invoice) return res.status(404).json({ error: 'Not found' }); + if (invoice.owner_id !== req.user.id) { + return res.status(403).json({ error: 'Forbidden' }); + } return res.json(invoice); });
Race condition in balance update. The read-then-write pattern at lines 23–28 is not atomic. If two concurrent requests both read balance = 100 and then write 100 + 50, the final balance is 150 instead of 200 — one credit is silently lost. Under load, this divergence compounds. Use a single atomic SQL update instead. Severity: High.
async function creditBalance(userId, amount) { + // Atomic increment — Postgres handles the read internally under a row lock + await pool.query( + 'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2', + [amount, userId] + ); }
Hardcoded credentials committed to source control. A database password and live Stripe secret key are in plaintext in a committed file. Anyone with repo access — past or present — can extract these. Git history makes this permanent even after deletion. Rotate both credentials immediately and move all secrets to environment variables. Severity: Critical.
// config/database.js — read from environment, never hardcode module.exports = { host: process.env.DB_HOST, database: process.env.DB_NAME, user: process.env.DB_USER, + password: process.env.DB_PASSWORD, // set in .env (local) or infra secrets (prod) + stripe_secret: process.env.STRIPE_SECRET_KEY, }; // Add config/database.js values to .env (gitignored) and // provision real secrets via your deploy platform's secret store.
Swallowed error returns false success to caller. The catch block logs the error but then returns { success: true } — the caller has no way to know the email failed. Every downstream system (retry logic, analytics, user notifications) believes the email was delivered. This pattern makes silent failures the default and makes debugging nearly impossible under load. Severity: Medium.
export async function sendWelcomeEmail(userId: string): Promise<void> { try { const user = await db.users.findById(userId); await mailer.send({ to: user.email, template: 'welcome' }); } catch (err) { // Log for observability, then re-throw so the caller can retry / alert + logger.error({ userId, err }, 'welcome email failed'); + throw err; // propagate — don't mask failures } }
Loopback connects to GitHub in under 5 minutes and starts reviewing every PR automatically — security, logic, and style findings, inline.