Skip to main content
Auth, Identity & Security
🔐 Auth & SecurityLesson 6 of 13

The OWASP Top 10, Part 1

Injection, broken auth, and XSS — the first half of the hacks that actually happen.

The OWASP Top 10, Part 1

Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L

You shipped a feature over the weekend, your users love it, and someone just emptied every account in your database. The code worked. The demo was flawless. You just forgot that "it works" and "it's secure" are two completely different sentences.


⚠️ The vibe trap

When you're building fast, the app is the feature and security is the footnote. The OWASP Top 10 is the industry's list of the most common ways that attitude ends careers and companies. Every vulnerability on this list has been exploited in production — repeatedly, at scale — not by elite hackers but by anyone who can type a URL or run a script they found online. Vibe coding got you to a working app; this lesson keeps it standing.


🔓 1. Broken Access Control / IDOR

What it is. Your route hands back data keyed on a user-supplied ID without checking whether the requesting user actually owns that record. An attacker just increments the ID in the URL and reads everyone else's data. This is called Insecure Direct Object Reference (IDOR) and it is the single most commonly exploited web vulnerability according to OWASP.

Real-world consequence. In 2021, millions of Parler users' private messages were scraped in hours because account IDs were sequential and ownership was never verified. In 2023 a major European bank exposed every customer's statement through a single digit change in the document URL.

Vulnerable — no ownership check:

// GET /api/orders/:id
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  // BUG: fetches ANY order by ID, never checks who owns it
  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1',
    [req.params.id]
  );
  res.json(order.rows[0]);
});

Fixed — tie the record to the authenticated user:

// GET /api/orders/:id
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  // Correct: the WHERE clause requires BOTH the ID AND the caller's user_id
  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
    [req.params.id, req.user.id]   // req.user.id comes from your verified JWT/session
  );
  if (!order.rows[0]) return res.status(404).json({ error: 'Not found' });
  res.json(order.rows[0]);
});

Why it works. The database does the access check atomically. Even if an attacker knows a valid order ID, the query returns nothing unless the ID belongs to them. Never trust a client-supplied ID alone.


🔑 2. Cryptographic Failures

What it is. Sensitive data is transmitted or stored without adequate protection: passwords hashed with MD5 or SHA-1, credit card numbers in plaintext, API keys committed to git, or personal data sent over HTTP. The category used to be called "Sensitive Data Exposure" because the failure is often in what you forgot to protect, not a broken algorithm per se.

Real-world consequence. The 2013 Adobe breach exposed 153 million passwords. They were encrypted (not hashed), all with the same key, making them trivially crackable via frequency analysis. Tens of thousands of companies have had AWS keys exposed in public GitHub repos and received five-figure cloud bills within hours.

Vulnerable — MD5-hashed password and a secret in source:

const crypto = require('crypto');

// BUG 1: MD5 is not a password hashing function — it's fast, which is the problem
function hashPassword(password) {
  return crypto.createHash('md5').update(password).digest('hex');
}

// BUG 2: secret committed to source control — now it lives in git history forever
const JWT_SECRET = 'supersecret123';

Fixed — bcrypt for passwords, environment variables for secrets:

const bcrypt = require('bcrypt');

// Correct: bcrypt is designed to be slow; cost factor 12 means ~250ms per hash
async function hashPassword(password) {
  return bcrypt.hash(password, 12);
}

async function verifyPassword(password, hash) {
  return bcrypt.compare(password, hash);  // constant-time comparison built in
}

// Correct: secret lives only in the environment, never in code
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('JWT_SECRET env var is required');

Why it works. bcrypt's work factor makes brute-force attacks computationally expensive even with modern hardware. Pulling secrets from environment variables keeps them out of source control and allows rotation without a code deploy.


💉 3. Injection

What it is. User input is interpreted as code rather than data. SQL injection is the classic variant: an attacker crafts input that rewrites your query. The same principle applies to NoSQL databases (MongoDB's $where operator), OS commands (child_process.exec with user input), LDAP queries, and template engines.

Real-world consequence. SQL injection has been responsible for some of the largest data breaches in history, including Heartland Payment Systems (130 million card numbers, 2008) and countless smaller breaches that never make the news. A single unsanitized input field is enough.

Vulnerable — string-concatenated SQL query:

// POST /api/login
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  // BUG: attacker sends email: "' OR '1'='1" and logs in as anyone
  const result = await db.query(
    `SELECT * FROM users WHERE email = '${email}' AND password_hash = '${password}'`
  );
  if (result.rows[0]) res.json({ token: generateToken(result.rows[0]) });
  else res.status(401).json({ error: 'Invalid credentials' });
});

Fixed — parameterized query (prepared statement):

// POST /api/login
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  // Correct: $1 and $2 are placeholders — the driver sends data and query separately
  // The database engine NEVER interprets the values as SQL syntax
  const result = await db.query(
    'SELECT * FROM users WHERE email = $1',
    [email]
  );

  const user = result.rows[0];
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const valid = await bcrypt.compare(password, user.password_hash);
  if (!valid) return res.status(401).json({ error: 'Invalid credentials' });

  res.json({ token: generateToken(user) });
});

Why it works. With parameterized queries the database driver transmits the SQL template and the parameter values in separate wire-protocol messages. The database engine parses the template first, then substitutes the values as pure data — there is no string interpolation and therefore no injection surface. This is the correct fix for every database, every language, every framework. Input sanitization is not a substitute.

A word on command injection. The same logic applies to shell commands:

// BUG: never pass user input to exec/spawn with shell: true
const { exec } = require('child_process');
exec(`convert ${req.body.filename} output.png`);  // attacker sends "x; rm -rf /"

// Correct: use spawn with an argument array, never a shell string
const { spawn } = require('child_process');
spawn('convert', [req.body.filename, 'output.png']);  // filename is pure data

🏗️ 4. Insecure Design

What it is. A class of vulnerabilities that cannot be patched away because the flaw is in how the feature was conceived, not how it was coded. No amount of input validation fixes a password-reset flow that emails a reset link to an address the attacker supplied. No rate-limiting fully compensates for an API that returns 200 responses that leak user existence.

Real-world consequence. Instagram's "Find Friends by Phone Number" feature was designed to accept arbitrary phone numbers and return whether an account existed. Scrapers harvested hundreds of millions of phone-to-username mappings. The design was the vulnerability.

The mindset shift. Before you write a line of code, ask: "What is the worst thing an adversary could do with this feature if they used it exactly as designed?" Threat model at the whiteboard, not in the post-mortem. Questions to ask:

  • Can this endpoint be abused at scale? Add rate limiting by design.
  • Does success vs. failure reveal information? Return identical error messages for "user not found" and "wrong password."
  • Does this flow require that only the legitimate owner can complete it? Verify identity at every step, not just the first.

No code to fix here — the fix is a habit of mind you build before writing code.


⚙️ 5. Security Misconfiguration

What it is. The application or its infrastructure is deployed with insecure default settings: debug mode left on in production, default admin credentials unchanged, CORS set to *, S3 buckets public by default, database admin interfaces exposed to the internet, verbose error messages that print stack traces.

Real-world consequence. In 2017 a misconfigured MongoDB instance with no authentication exposed 200,000 patient records at a US health system. In 2019 Capital One was breached via a misconfigured AWS WAF. The Shodan search engine indexes thousands of databases exposed to the public internet right now, most with default credentials.

Vulnerable — several misconfigurations in one Express app:

const app = express();

// BUG 1: stack traces sent to the client in every environment
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.stack });
});

// BUG 2: CORS allows every origin — fine for a public API, catastrophic for an
// authenticated one because it defeats same-origin protection
app.use(cors({ origin: '*', credentials: true }));

// BUG 3: X-Powered-By header tells attackers your exact stack version
// (Express leaves this on by default)

Fixed — production-hardened configuration:

const app = express();

// Correct: hide implementation details from clients
app.disable('x-powered-by');

// Correct: helmet sets secure HTTP headers in one line
const helmet = require('helmet');
app.use(helmet());

// Correct: restrict CORS to your known front-end origins
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',');
app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) callback(null, true);
    else callback(new Error('CORS: origin not allowed'));
  },
  credentials: true,
}));

// Correct: generic error message in production, full detail only in development
app.use((err, req, res, next) => {
  const isDev = process.env.NODE_ENV === 'development';
  console.error(err);  // log the real error server-side
  res.status(500).json({
    error: isDev ? err.stack : 'An internal error occurred.',
  });
});

Why it works. helmet applies a dozen security-relevant HTTP headers (Content-Security-Policy, X-Frame-Options, etc.) that most applications need and nobody remembers to set individually. Restricting CORS to known origins means an attacker's website cannot make credentialed cross-origin requests to your API from a victim's browser. Suppressing stack traces removes a roadmap attackers use to craft further exploits.


🛠️ Your mission

Open your current project and hunt for one of these five vulnerabilities. Good starting points:

  1. Search your route files for any query that uses string interpolation with req.body, req.params, or req.query — that is injection.
  2. Check every route that fetches a record by ID — does it verify ownership?
  3. Run grep -r "process.env" . and then check your .env.example; any secret that has a real default value in example files is a cryptographic failure waiting to happen.
  4. Check your cors() call. If origin is '*' and credentials is true, that is a misconfiguration.
  5. Open your error handler. Does it send err.stack or err.message without checking NODE_ENV?

Fix the one you find, document what it was, and add it to your Security Audit Checklist.


✅ You're done when…

  • You've added the five vulnerabilities above (Broken Access Control, Cryptographic Failures, Injection, Insecure Design, Security Misconfiguration) and their mitigations to your Security Audit Checklist for this project
  • Every database query in your project that uses user-supplied input uses parameterized queries — no string concatenation in sight
  • Your CORS configuration names specific allowed origins rather than '*'
  • Your production error handler does not send stack traces or internal error messages to clients
  • You have verified that no credentials, API keys, or JWT secrets appear anywhere in your source code or commit history (check with git log -S "SECRET" --all if needed)

➡️ Next: The OWASP Top 10, Part 2. Build It Right, Or Don't Build It At All. 🏛️

Pre-launch security · partner tool

Built it? Now scan it. The HYVE Audit finds security holes before launch — $55, and your code never leaves your machine.

Run the audit ↗

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.