AboutBlogContact
SecurityApril 18, 2026 8 min read 2

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

AunimedaAunimeda
📋 Table of Contents

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

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

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.

OpenSSH Tunneling: Securing Database Connections Over Public Networks (2006)aunimeda
Security

OpenSSH Tunneling: Securing Database Connections Over Public Networks (2006)

Exposing MySQL or PostgreSQL to the public internet is a recipe for disaster. In 2006, SSH tunneling is the gold standard for secure remote database administration.

Need IT development for your business?

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

Get Consultation All articles