Most developers ship with five common web security holes: SQL injection, XSS, CSRF, exposed secrets, and missing input validation. These aren’t exotic attacks—they’re the ones that actually happen. This post walks through each one, shows how real attacks work, and the five-minute fixes that stop them. You don’t need to be a security expert; you just need to avoid the obvious stuff.
The Bug Bounty Report That Made Me Nervous
A security researcher found five vulnerabilities in a codebase I’d worked on. Not theoretical ones—actual, exploitable holes. The worst part? They were all basic. No sophisticated attacks. Just the same mistakes I’d seen a hundred times.
I realized: most developers know security is important but don’t know what to actually check for. We ship broken authentication, exposed API keys, and query strings with sensitive data. Not because we’re bad engineers. Because we don’t know what we’re looking for.
Here are the five holes that actually matter.
Hole #1: SQL Injection (And It’s Still Alive)
SQL injection is building SQL strings by concatenating user input. An attacker can inject malicious SQL commands by crafting input that breaks out of your query. The key risk is that an attacker can read, modify, or delete your entire database.
This is 2026 and people are still doing this:
// VULNERABLE: Never do this
app.get('/user', (req, res) => {
const userId = req.query.id;
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.execute(query, (err, results) => {
res.json(results);
});
});
// Attack: ?id=1 OR 1=1
// This becomes: SELECT * FROM users WHERE id = 1 OR 1=1
// Returns every user in the database
The fix is trivial: use parameterized queries.
// SAFE: Use parameterized queries
app.get('/user', (req, res) => {
const userId = req.query.id;
const query = 'SELECT * FROM users WHERE id = ?';
db.execute(query, [userId], (err, results) => {
res.json(results);
});
});
// The driver handles escaping. User input is data, not SQL.
With parameterized queries, the database driver treats user input as data, not code. An attacker can’t inject SQL. This applies to every database—MySQL, PostgreSQL, MongoDB (with proper driver usage), all of them.
If you’re using an ORM like Sequelize, TypeORM, or Prisma, they use parameterized queries by default. But if you’re writing raw SQL, always use parameters.
// TypeORM example (safe by default)
const user = await db.users.findOne({ where: { id: userId } });
// Prisma example (safe)
const user = await prisma.users.findUnique({ where: { id: userId } });
// Raw SQL with parameters (safe)
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
Checklist: Search your codebase for string concatenation in SQL queries. If you find any, convert to parameterized queries immediately.
Hole #2: Cross-Site Scripting (XSS)
XSS is injecting malicious JavaScript that runs in the browser. An attacker can steal sessions, redirect users, modify page content, or steal data. It works by injecting scripts into the page that the browser treats as legitimate code.
There are two types:
Stored XSS (Persistent)
Malicious script is saved to the database and served to every user.
// VULNERABLE: User-submitted content stored without sanitization
app.post('/comment', (req, res) => {
const comment = req.body.comment;
db.comments.insert({ text: comment });
res.json({ success: true });
});
// In the template:
// <div><%= comment.text %></div>
// Attack: User submits comment containing:
// <img src=x onerror="fetch('/steal-session?session=' + document.cookie)">
// Every user who loads this page runs that script.
The fix: escape HTML when rendering user content.
// In your template (React example):
// React escapes by default
<div>{comment.text}</div>
// Node.js with Express (use a library)
import escapeHtml from 'escape-html';
app.get('/comments', (req, res) => {
const comments = db.comments.find();
res.send(comments.map(c => `<div>${escapeHtml(c.text)}</div>`).join(''));
});
// Or in template engines (EJS, Handlebars):
// <div><%- comment.text %></div> - escapes by default
// <div><%- include('comment-partial', comment) %></div>
Reflected XSS
Malicious script is in a URL parameter and reflected back in the response.
// VULNERABLE
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Search results for: ${query}</h1>`);
});
// Attack: /search?q=<img src=x onerror="steal()">
// The response contains the script, browser executes it.
The fix: escape output or use templating engines that escape by default.
import escapeHtml from 'escape-html';
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Search results for: ${escapeHtml(query)}</h1>`);
});
// Now: /search?q=<img src=x onerror="steal()">
// Renders as: <h1>Search results for: <img src=x onerror="steal()"></h1>
// Browser displays the text, doesn't execute it.
Checklist: Anywhere user input is displayed, make sure it’s escaped. React escapes by default. Node templates vary—check your engine’s documentation. Never use dangerouslySetInnerHTML or unescape() without extreme care.
Hole #3: CSRF (Cross-Site Request Forgery)
CSRF is making the user’s browser execute requests on a different site. An attacker tricks you into transferring money, changing your password, or deleting data on another site.
Here’s the attack:
// You're logged into your bank at bank.com
// Attacker sends you a link to evilsite.com
// evilsite.com contains:
// <img src="https://bank.com/transfer?to=attacker&amount=10000">
// Your browser sees an <img> tag, tries to load it.
// You're logged into bank.com, so the request includes your session.
// The bank processes the transfer because you're authenticated.
// You never knew it happened.
The fix: CSRF tokens. The server generates a token, includes it in forms, and validates it on submission.
// Using csurf middleware in Express
import csrf from 'csurf';
import cookieParser from 'cookie-parser';
const csrfProtection = csrf({ cookie: true });
app.get('/transfer', csrfProtection, (req, res) => {
// Generate CSRF token
res.render('transfer', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// Token is validated automatically by middleware
// If token is missing or invalid, request is rejected
const { to, amount } = req.body;
db.transfer(req.user.id, to, amount);
res.json({ success: true });
});
// In the form:
// <form method="POST" action="/transfer">
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">
// <input name="to" placeholder="Transfer to">
// <input name="amount" placeholder="Amount">
// <button>Transfer</button>
// </form>
// Attacker can't forge the token because:
// 1. Token is unique per session
// 2. Only your site knows the secret
// 3. Token can't be read from another domain (same-origin policy)
For API endpoints (non-form requests), use the SameSite cookie attribute instead of tokens:
// Set cookies with SameSite=Strict
app.use(session({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict' // Cookie only sent to same site
}
}));
// Now evilsite.com can't make requests to your API using your session.
Checklist: Every form that modifies data should have a CSRF token. Every session cookie should have sameSite set to ‘strict’ or ‘lax’.
Hole #4: Exposed Secrets and API Keys
Developers commit API keys, database passwords, and secrets to version control. Git history is permanent. Once it’s there, it’s there forever.
// VULNERABLE: Committed to Git
const API_KEY = 'sk_live_abc123xyz789';
const DB_PASSWORD = 'admin123password';
fetch(`https://api.stripe.com/v1/charges?key=${API_KEY}`, {
method: 'POST',
body: JSON.stringify({ amount: 1000 })
});
An attacker finds your repo, reads the commit history, finds the key, and uses it.
The fix: environment variables and .env files (not committed).
# .env (this file is in .gitignore, never committed)
STRIPE_API_KEY=sk_live_abc123xyz789
DATABASE_PASSWORD=admin123password
import dotenv from 'dotenv';
dotenv.config();
const API_KEY = process.env.STRIPE_API_KEY;
const DB_PASSWORD = process.env.DATABASE_PASSWORD;
fetch(`https://api.stripe.com/v1/charges?key=${API_KEY}`, {
method: 'POST',
body: JSON.stringify({ amount: 1000 })
});
# .gitignore
.env
.env.local
node_modules/
In production, use your platform’s secrets management (AWS Secrets Manager, Vercel Environment Variables, Heroku Config Vars, etc.).
Checklist: Search your Git history for API keys. If you find any, rotate them immediately. Any secret committed to GitHub is compromised (GitHub’s secret scanning will alert you). Use environment variables. Never log secrets.
Hole #5: Missing Input Validation
Input validation is checking that data is what you expect before you use it. Without it, users send bad data that crashes your app or bypasses security.
// VULNERABLE: No validation
app.post('/user', (req, res) => {
const { email, age, isAdmin } = req.body;
// What if someone sends:
// { email: 123, age: "not a number", isAdmin: true }
// Or omits required fields?
db.users.insert({ email, age, isAdmin });
res.json({ success: true });
});
// Attack: Regular user sends { isAdmin: true }
// Gets admin access.
The fix: validate and sanitize input.
import { body, validationResult } from 'express-validator';
app.post('/user',
// Validation rules
body('email').isEmail(),
body('age').isInt({ min: 0, max: 150 }),
body('isAdmin').optional().isBoolean(),
// Handler
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, age } = req.body;
const isAdmin = false; // Never trust client input for sensitive fields
db.users.insert({ email, age, isAdmin });
res.json({ success: true });
}
);
Key principles:
- Never trust client input. Validate on the server, always.
- Whitelist expected data types and formats. Reject everything else.
- For sensitive fields (isAdmin, roles, permissions), never read from user input. Calculate server-side based on authentication.
- Use a library like express-validator or zod instead of writing validation manually.
// Using zod (highly recommended)
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(0).max(150),
password: z.string().min(8)
});
app.post('/user', (req, res) => {
try {
const userData = userSchema.parse(req.body);
db.users.insert(userData);
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: err.errors });
}
});
Checklist: Every endpoint that accepts input should validate it. No exceptions. Use a validation library. Never trust that data is the type you expect.
Bonus Hole #6: Unencrypted Connections and Missing HTTPS
If your app isn’t served over HTTPS, everything is readable over the network. Passwords, tokens, data—all visible to anyone on the same WiFi.
The fix: HTTPS everywhere. It’s 2026. There’s no excuse.
- Use Let’s Encrypt (free) for SSL certificates.
- Redirect HTTP to HTTPS.
- Set the Strict-Transport-Security header.
// Express example
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
One More Thing: Security Headers
Set these headers to block common attacks:
// Using helmet (recommended)
import helmet from 'helmet';
app.use(helmet());
// Or manually:
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', "default-src 'self'");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
});
Content-Security-Policy is the big one. It tells the browser what resources are allowed. Blocks inline scripts, cross-origin scripts, and malicious injections.
Security Checklist Before Shipping
- [ ] All database queries use parameterized queries
- [ ] User input is escaped when displayed in HTML
- [ ] Forms include CSRF tokens; APIs use SameSite cookies
- [ ] No secrets in code or Git history
- [ ] All endpoints validate and sanitize input
- [ ] App runs over HTTPS with redirects and HSTS header
- [ ] Security headers are set (CSP, X-Frame-Options, etc.)
- [ ] Passwords are hashed with bcrypt or argon2 (not plain text)
- [ ]Sessions or tokens expire and are validated on every request
- [ ] Error messages don’t expose system details
FAQ
Is my app secure if it passes these checks?
No. These are the five most common holes. There are dozens more. But if you fix these five, you’ve eliminated 80% of real attacks. The remaining 20% require deeper security knowledge.
Do I need a security expert?
For startups and small companies, no. Fix the basics, keep frameworks updated, and audit your code. For sensitive systems (financial, healthcare, government), yes. Hire a security firm.
How often should I audit for security?
Before every major release, and quarterly. Use automated scanning tools (dependency checkers, SAST) in your CI. They catch obvious stuff.
What’s the best security library for my framework?
Node/Express: helmet (headers), express-validator (input), bcryptjs (passwords). React: sanitize user content, use CSP. Always check your framework’s security guide.
Should I hire a bug bounty program?
If you have users and data, yes. Platforms like HackerOne let security researchers responsibly report issues. It’s cheaper than getting breached.
How do I stay updated on security issues?
Follow security mailing lists (Node.js, your framework). Use dependabot or snyk to monitor dependencies. Read the OWASP Top 10 (updated every few years).
Here’s the real secret about web security: it’s not complicated. It’s not about advanced cryptography or sophisticated attacks. It’s about closing obvious holes and following best practices. The five vulnerabilities in this post account for the vast majority of breaches. Fix them, and you’re already ahead of 90% of websites out there.