Web Security for Developers: The Essentials You Can't Skip
The security vulnerabilities that appear most often in web applications, explained with real code examples and concrete fixes every developer should know.

Security Is Not a Sprint at the End
Most security vulnerabilities aren't sophisticated attacks. They're basic mistakes made under deadline pressure — missing input validation, leaked secrets, forgotten authorization checks. This guide covers the practical fundamentals every developer should internalize.
Vulnerability 1: SQL Injection
Still the most common critical vulnerability in web applications, despite being solved decades ago.
// ❌ Never concatenate user input into SQL
async function getUser(username: string) {
// If username = "admin' OR '1'='1" — returns all users
const query = `SELECT * FROM users WHERE username = '${username}'`;
return db.query(query);
}
// ✅ Always use parameterized queries
async function getUser(username: string) {
return db.query("SELECT * FROM users WHERE username = $1", [username]);
}
// ✅ Or use a query builder / ORM
async function getUser(username: string) {
return prisma.user.findUnique({ where: { username } });
}Parameterized queries are non-negotiable. There is no safe way to concatenate user input into SQL.
Vulnerability 2: Cross-Site Scripting (XSS)
XSS allows attackers to inject scripts into your pages that run in other users' browsers — stealing sessions, exfiltrating data, or hijacking accounts.
// ❌ Never render raw HTML from untrusted sources
function Comment({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// If content = "<script>document.cookie</script>" — you've been XSS'd
// ✅ React escapes by default — use it
function Comment({ content }: { content: string }) {
return <div>{content}</div>; // Safe — React escapes HTML entities
}
// ✅ If you must render HTML, sanitize first
import DOMPurify from "dompurify";
function RichContent({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "b", "i", "em", "strong", "a"],
ALLOWED_ATTR: ["href", "title"],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}Also set a strict Content Security Policy to limit what scripts can execute even if XSS does occur.
Vulnerability 3: Broken Access Control
Authorization bugs are subtle because the code usually works — it just works for the wrong users.
// ❌ Missing authorization check — any authenticated user can delete any comment
app.delete("/api/comments/:id", authenticate, async (req, res) => {
await db.comment.delete({ where: { id: req.params.id } });
res.json({ success: true });
});
// ✅ Verify ownership before mutation
app.delete("/api/comments/:id", authenticate, async (req, res) => {
const comment = await db.comment.findUnique({
where: { id: req.params.id },
});
if (!comment) {
return res.status(404).json({ error: "Not found" });
}
if (comment.authorId !== req.user.id) {
return res.status(403).json({ error: "Forbidden" });
}
await db.comment.delete({ where: { id: req.params.id } });
res.json({ success: true });
});For complex applications, use a dedicated authorization library (Casbin, Permit.io, or a custom RBAC implementation) rather than ad-hoc if checks scattered throughout the codebase.
Vulnerability 4: Insecure Secret Management
Secrets in code are eventual leaks. Every API key committed to a repository should be considered compromised.
# ❌ Hard-coded secrets
DATABASE_URL="postgresql://admin:password123@prod-db/app"
STRIPE_SECRET_KEY="sk_live_..."
# ✅ Environment variables — never committed
echo ".env.local" >> .gitignore
echo ".env*.local" >> .gitignore// ✅ Validate secrets at startup, fail fast if missing
const requiredEnvVars = [
"DATABASE_URL",
"JWT_SECRET",
"STRIPE_SECRET_KEY",
] as const;
for (const key of requiredEnvVars) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}Use a secrets manager (AWS Secrets Manager, Doppler, HashiCorp Vault) for production. Rotate any key that was ever committed to a repository, even if the commit was reverted.
Vulnerability 5: Weak Authentication
JWT secrets with default values, sessions that never expire, passwords stored in plaintext — these are the authentication patterns that lead to breaches.
// ❌ Weak JWT configuration
const token = jwt.sign({ userId }, "secret"); // No expiry, weak secret
// ✅ Proper JWT configuration
const token = jwt.sign(
{ userId, iat: Math.floor(Date.now() / 1000) },
process.env.JWT_SECRET!, // Strong, random secret from env
{
expiresIn: "15m", // Short-lived access tokens
issuer: "myapp.com",
audience: "api.myapp.com",
},
);
// ✅ Password hashing — always use bcrypt or argon2
import { hash, verify } from "argon2";
async function hashPassword(password: string): Promise<string> {
return hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
parallelism: 1,
timeCost: 2,
});
}
async function verifyPassword(
hash: string,
password: string,
): Promise<boolean> {
return verify(hash, password);
}Security Headers Checklist
// next.config.ts — minimum security headers for any web app
const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{NONCE}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.myapp.com",
].join("; "),
},
];Run your application through securityheaders.com and OWASP ZAP before launch. Add these checks to your CI pipeline.
Security is not about being paranoid. It's about understanding how your application handles untrusted data and making sure every path is intentional.


