AboutBlogContact
SecurityApril 18, 2026 9 min readUpdated: July 1, 2026

OWASP Top 10 2025: Web Application Security Guide with Real Attack Examples

AunimedaAunimeda
📋 Table of Contents

The OWASP Top 10 is the industry standard for web application security risk classification. Every developer building web apps needs to understand these. Not as abstract theory - as specific bugs that will happen in your codebase if you're not careful.


A01: Broken Access Control

The #1 risk for the third consecutive cycle. Access control failures allow attackers to act outside their intended permissions.

The attack:

GET /api/users/1234/orders        ← attacker's own account
GET /api/users/5678/orders        ← victim's account (same endpoint, different ID)

If your API checks only authentication (is the user logged in?) but not authorization (does this user own resource 5678?), this is Broken Access Control - also called IDOR (Insecure Direct Object Reference).

Vulnerable code:

// ❌ Only checks if logged in, not if user owns the resource
router.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.order.findUnique({ where: { id: req.params.orderId } });
  res.json(order);
});

Fix:

// ✅ Scope every query to the authenticated user
router.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.order.findUnique({
    where: {
      id: req.params.orderId,
      userId: req.user.id, // ← user can only access their own orders
    },
  });
  
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});

For admin endpoints - explicitly check role, don't rely on "only admins know the URL":

function requireRole(role: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user.roles.includes(role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

router.delete('/api/admin/users/:id', authenticate, requireRole('admin'), deleteUser);

A02: Cryptographic Failures

Previously called "Sensitive Data Exposure" - it's about failing to protect data at rest and in transit.

Common failures:

  1. Storing passwords in plaintext or with weak hashing (MD5, SHA1)
  2. Transmitting sensitive data over HTTP instead of HTTPS
  3. Weak random number generation for tokens

Vulnerable code:

// ❌ MD5 is not a password hashing algorithm
const hashedPassword = crypto.createHash('md5').update(password).digest('hex');

// ❌ Predictable token generation
const resetToken = Math.random().toString(36);

Fix:

import bcrypt from 'bcrypt';
import crypto from 'crypto';

// ✅ bcrypt: slow by design (cost factor 12 = ~250ms per hash)
const SALT_ROUNDS = 12;

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// ✅ Cryptographically secure random token
function generateSecureToken(bytes = 32): string {
  return crypto.randomBytes(bytes).toString('hex');
}

For JWT secret keys - minimum 256-bit secret, ideally asymmetric (RS256):

// ✅ RS256: private key to sign, public key to verify
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';

const { privateKey, publicKey } = await generateKeyPair('RS256');

const token = await new SignJWT({ userId, role })
  .setProtectedHeader({ alg: 'RS256' })
  .setIssuedAt()
  .setExpirationTime('15m') // Short expiry
  .sign(privateKey);

A03: Injection

SQL, NoSQL, OS command, LDAP - anything where untrusted input reaches an interpreter.

SQL injection example:

// ❌ Classic SQL injection
const results = await db.query(
  `SELECT * FROM users WHERE email = '${req.body.email}'`
);
// Input: admin'-- → logs in as admin without password
// Input: '; DROP TABLE users;-- → destroys your database

Fix with parameterized queries:

// ✅ Parameterized query - input is never interpreted as SQL
const results = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]
);

// ✅ ORM (Prisma, Drizzle) - parameterized by default
const user = await prisma.user.findUnique({
  where: { email: req.body.email },
});

NoSQL injection (MongoDB):

// ❌ Vulnerable - attacker sends: { "$gt": "" } as email
const user = await User.findOne({ email: req.body.email });

// Attacker payload: POST /login { "email": {"$gt": ""}, "password": {"$gt": ""} }
// Result: returns the first user in the database → bypasses auth

Fix:

import { z } from 'zod';

// ✅ Validate that email is actually a string before touching DB
const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

const { email, password } = LoginSchema.parse(req.body);
const user = await User.findOne({ email }); // safe - email is a validated string

A04: Insecure Design

Missing security controls by design - not implementation bugs but architectural ones.

Example: Password reset via predictable token:

// ❌ Time-based predictable token
function generateResetToken(userId: string): string {
  return Buffer.from(`${userId}:${Date.now()}`).toString('base64');
}
// Attacker can brute-force timestamp range for a known userId

Secure design:

// ✅ Cryptographically random token stored server-side with expiry
async function createPasswordResetToken(userId: string): Promise<string> {
  const token = crypto.randomBytes(32).toString('hex');
  const hash = crypto.createHash('sha256').update(token).digest('hex');
  
  await db.passwordReset.create({
    data: {
      userId,
      tokenHash: hash, // store hash, not the token itself
      expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
      usedAt: null,
    },
  });
  
  return token; // send to user, never stored plaintext
}

async function verifyResetToken(token: string): Promise<string | null> {
  const hash = crypto.createHash('sha256').update(token).digest('hex');
  
  const record = await db.passwordReset.findFirst({
    where: {
      tokenHash: hash,
      expiresAt: { gt: new Date() },
      usedAt: null,
    },
  });
  
  return record?.userId ?? null;
}

A05: Security Misconfiguration

Default credentials, verbose error messages, unnecessary features enabled, missing security headers.

Security headers (critical for any web app):

import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"], // tighten as needed
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'https://api.yourservice.com'],
    },
  },
  hsts: {
    maxAge: 63072000, // 2 years
    includeSubDomains: true,
    preload: true,
  },
}));

Never expose stack traces in production:

// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // Log full error internally
  logger.error({ err, url: req.url, userId: req.user?.id });
  
  // Return generic error to client
  if (process.env.NODE_ENV === 'production') {
    res.status(500).json({ error: 'Internal server error' });
  } else {
    res.status(500).json({ error: err.message, stack: err.stack });
  }
});

A06: Vulnerable and Outdated Components

Using packages with known CVEs.

# Audit your dependencies
npm audit

# Auto-fix non-breaking updates
npm audit fix

# Check for outdated packages
npm outdated

# Better: integrate into CI
npx audit-ci --moderate

In your CI pipeline:

# .github/workflows/security.yml
- name: Security audit
  run: npm audit --audit-level=moderate
  
- name: Check for known vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    scan-type: 'fs'
    severity: 'HIGH,CRITICAL'

A07: Identification and Authentication Failures

Weak passwords, missing brute-force protection, insecure session management.

Rate limiting on auth endpoints:

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 attempts per IP per window
  standardHeaders: true,
  store: new RedisStore({ client: redis }), // persistent across restarts
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many login attempts. Try again in 15 minutes.',
    });
  },
});

app.post('/api/auth/login', loginLimiter, loginHandler);

Never leak whether email exists:

// ❌ Leaks user enumeration
if (!user) return res.status(404).json({ error: 'User not found' });
if (!validPassword) return res.status(401).json({ error: 'Wrong password' });

// ✅ Same message regardless
if (!user || !await verifyPassword(password, user.passwordHash)) {
  return res.status(401).json({ error: 'Invalid email or password' });
}

A08: Software and Data Integrity Failures

Deserializing untrusted data, unsigned updates, CI/CD pipeline injection.

Never use eval() or deserialize untrusted JSON with prototypes:

// ❌ Prototype pollution via JSON merge
function merge(target: any, source: any) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}
// Source: {"__proto__": {"isAdmin": true}} → pollutes Object.prototype

// ✅ Use Object.create(null) or structured clone
const safeConfig = JSON.parse(JSON.stringify(userInput)); // strips prototype

A09: Security Logging and Monitoring Failures

If you can't detect a breach, you can't respond to it.

Minimum security events to log:

const securityLogger = {
  authFailure: (email: string, ip: string, reason: string) => {
    logger.warn({
      event: 'AUTH_FAILURE',
      email,
      ip,
      reason,
      timestamp: new Date().toISOString(),
    });
  },
  
  suspiciousActivity: (userId: string, action: string, details: object) => {
    logger.warn({
      event: 'SUSPICIOUS_ACTIVITY',
      userId,
      action,
      details,
      timestamp: new Date().toISOString(),
    });
  },
  
  accessDenied: (userId: string, resource: string) => {
    logger.warn({
      event: 'ACCESS_DENIED',
      userId,
      resource,
      timestamp: new Date().toISOString(),
    });
  },
};

Alert when: 5+ auth failures from one IP in 1 minute, access denied to admin resources, password changed for privileged user.


A10: Server-Side Request Forgery (SSRF)

Attacker tricks server into making HTTP requests to internal services.

// ❌ Vulnerable: fetches arbitrary URLs from user input
app.post('/api/preview', async (req, res) => {
  const { url } = req.body;
  const response = await fetch(url); // attacker sends http://169.254.169.254/latest/meta-data/
  res.json({ content: await response.text() });
});

The 169.254.169.254 is the AWS metadata service - this leaks IAM credentials.

Fix:

import { URL } from 'url';
import dns from 'dns/promises';
import ipRangeCheck from 'ip-range-check';

const BLOCKED_RANGES = [
  '10.0.0.0/8',      // Private network
  '172.16.0.0/12',   // Private network
  '192.168.0.0/16',  // Private network
  '127.0.0.0/8',     // Loopback
  '169.254.0.0/16',  // Link-local (AWS metadata)
  '::1/128',         // IPv6 loopback
];

async function isSafeUrl(urlString: string): Promise<boolean> {
  try {
    const url = new URL(urlString);
    if (!['http:', 'https:'].includes(url.protocol)) return false;
    
    const addresses = await dns.resolve4(url.hostname);
    return addresses.every(ip => !ipRangeCheck(ip, BLOCKED_RANGES));
  } catch {
    return false;
  }
}

app.post('/api/preview', async (req, res) => {
  const { url } = req.body;
  if (!await isSafeUrl(url)) {
    return res.status(400).json({ error: 'URL not allowed' });
  }
  // safe to fetch
});

Quick Security Checklist

[ ] All user input validated with schema (Zod/Joi)
[ ] Parameterized queries everywhere (no string concatenation in SQL)
[ ] Passwords hashed with bcrypt/argon2 (cost factor 10+)
[ ] Security headers configured (Helmet)
[ ] Rate limiting on auth endpoints
[ ] JWT with short expiry + refresh token rotation
[ ] npm audit in CI pipeline
[ ] Never expose stack traces in production
[ ] All access control checks scoped to authenticated user
[ ] HTTPS only, HSTS enabled
[ ] Structured security event logging

Aunimeda builds secure, production-grade applications. Discuss your project.

See also: Node.js vs Bun runtime comparison, TypeScript advanced types


Aunimeda builds secure software systems with security-first architecture - authentication, authorization, and security audits.

Contact us to discuss your project. See also: Custom Software Development

Read Also

Web App Security Checklist for 2026 - What Every Developer Must Knowaunimeda
Security

Web App Security Checklist for 2026 - What Every Developer Must Know

90% of web app breaches are preventable. This checklist covers the OWASP Top 10, authentication hardening, and the specific misconfigurations we see in audits repeatedly.

Authentication in 2026: JWT, OAuth 2.0, Passkeys, and When to Use Eachaunimeda
Security

Authentication in 2026: JWT, OAuth 2.0, Passkeys, and When to Use Each

Passwords are insecure. JWT has footguns. OAuth 2.0 is complex. Passkeys are finally real. This guide cuts through the confusion - what authentication mechanism to use for what use case, with code examples and the security pitfalls to avoid.

Node.js + TypeScript: Building a Production REST API from Scratch in 2026aunimeda
Backend Engineering

Node.js + TypeScript: Building a Production REST API from Scratch in 2026

A complete guide to building a production-ready REST API with Node.js and TypeScript - authentication, validation, error handling, rate limiting, logging, and deployment. No shortcuts.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles