Security is not a feature you add at the end of a project. It is a discipline woven into every line of code you write. The uncomfortable truth: most web application vulnerabilities are not exotic zero-day exploits — they are beginner-level mistakes that developers have been making since the early days of the web. SQL injection, XSS, insecure direct object references, hardcoded credentials — these consistently top the charts in breach reports year after year. This guide gives you the practical knowledge to avoid the most common ones, written specifically for developers who build apps with Node.js and similar stacks.
Security Is Every Developer's Responsibility
There is a persistent myth that security is "the security team's job." In organizations large enough to have dedicated security teams, those teams set policy and do audits — they do not review every pull request. The developer who writes the code is the first and most important line of defense.
More importantly: most developers today build APIs, SaaS products, and side projects without any security team at all. If you ship a vulnerable product, there is no safety net. The only protection is your own knowledge.
OWASP Top 10 Explained Simply
The Open Web Application Security Project (OWASP) publishes a list of the ten most critical web application security risks. The 2021 edition (still current) is the standard reference. Here is what each entry means in plain language:
A01 Broken Access Control: users can access data or perform actions they should not be able to. Example: a user changes the ID in a URL from /orders/123 to /orders/124 and sees someone else's order.
A02 Cryptographic Failures: sensitive data transmitted or stored without proper encryption. Storing passwords in plain text. Using MD5 for password hashing. Using HTTP instead of HTTPS.
A03 Injection: user input is interpreted as code. SQL injection, command injection, LDAP injection. The root cause is always the same: user data mixed with code without sanitization.
A04 Insecure Design: security flaws baked into the architecture, not just the implementation. No threat modelling done during design. "Admin mode" accessible to any user who knows the URL.
A05 Security Misconfiguration: default credentials left unchanged, verbose error messages exposing stack traces, debug mode enabled in production, open S3 buckets.
A06 Vulnerable and Outdated Components: using npm packages with known vulnerabilities. Not running npm audit.
A07 Identification and Authentication Failures: weak passwords allowed, no brute-force protection, session tokens not invalidated on logout.
A08 Software and Data Integrity Failures: not verifying the integrity of software updates or CI/CD pipeline artifacts.
A09 Security Logging and Monitoring Failures: no logging of failed login attempts, no alerts for suspicious activity.
A10 Server-Side Request Forgery (SSRF): the server fetches a URL provided by the user, allowing attackers to reach internal services.
SQL Injection Prevention
SQL injection is over 25 years old and still in the top three vulnerabilities in every major breach report. The attack works by inserting SQL syntax into user input that gets interpreted by the database. The classic example:
// VULNERABLE - never do this
const query = `SELECT * FROM users WHERE email = '${userEmail}'`;
// If userEmail = "' OR '1'='1", this returns all users
// SAFE - use parameterized queries
const { rows } = await pool.query(
'SELECT * FROM users WHERE email = $1',
[userEmail]
);
// SAFE with Prisma (parameterized automatically)
const user = await prisma.user.findUnique({
where: { email: userEmail }
});
// SAFE with Knex
const user = await db('users').where({ email: userEmail }).first();
The rule is simple: never concatenate user input into SQL strings. Always use parameterized queries or an ORM. This applies equally to MongoDB: never pass unsanitized user input directly as query operators.
XSS Prevention
Cross-Site Scripting (XSS) allows attackers to inject malicious JavaScript into pages viewed by other users. Stored XSS is the most dangerous: the attacker saves malicious script in your database (e.g., in a comment), and it executes for every user who views that comment.
// VULNERABLE - rendering unsanitized HTML
element.innerHTML = userProvidedContent;
// SAFE - use textContent for plain text
element.textContent = userProvidedContent;
// SAFE - sanitize when HTML rendering is required
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userProvidedContent);
// Server-side: escape HTML entities before storing/rendering
import { escape } from 'html-escaper';
const safe = escape(userInput);
React and most modern frameworks auto-escape by default when you use JSX — {variable} is safe. Only dangerouslySetInnerHTML bypasses this, and you must sanitize before using it.
CSRF Protection
Cross-Site Request Forgery tricks a logged-in user's browser into making unintended requests to your app. If a user is logged in to your banking app and visits a malicious site, that site can silently submit a form to bank.com/transfer. The browser automatically includes the session cookie.
The standard protection is CSRF tokens — a unique, unpredictable value included in every state-changing form that the server validates. For APIs using Authorization: Bearer headers (not cookies), CSRF is not a concern because browsers do not automatically attach custom headers to cross-origin requests.
// Express with csurf middleware
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/submit', csrfProtection, (req, res) => {
// CSRF token validated automatically
// Process the request
});
Authentication Security
Authentication is the most targeted layer of any web app. The key practices:
Password hashing: never store passwords in plain text or with reversible encryption. Use bcrypt with a work factor of 12+. The work factor makes brute-force attacks exponentially more expensive:
import bcrypt from 'bcrypt';
// Hashing (on registration/password change)
const SALT_ROUNDS = 12;
const hashedPassword = await bcrypt.hash(plainPassword, SALT_ROUNDS);
// Verifying (on login)
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
if (!isValid) throw new Error('Invalid credentials');
JWT best practices: use short expiry times (15–60 minutes for access tokens), use a separate long-lived refresh token stored in an httpOnly cookie, sign with RS256 (asymmetric) in production, never put sensitive data in the payload (it is base64-encoded, not encrypted), and rotate signing secrets periodically.
Rate limiting on auth endpoints: limit login attempts to 5 per IP per 15 minutes. This stops brute-force attacks cold without significantly impacting legitimate users.
HTTPS and TLS
Never ship a production app over HTTP. HTTPS encrypts all data in transit, preventing man-in-the-middle attacks. In 2026, there is no excuse not to have HTTPS — Let's Encrypt provides free SSL certificates, Certbot automates renewal, and hosting platforms like Vercel, Netlify, and Railway provision certificates automatically.
Enable HSTS (HTTP Strict Transport Security) to tell browsers to always use HTTPS for your domain, even if the user types HTTP. Set a long max-age (at least 1 year) and include subdomains once your entire domain is HTTPS.
Security Headers
HTTP security headers are free, easy to implement, and provide significant protection against a range of attacks. Use the Helmet.js middleware in Express to set them all at once:
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://www.googletagmanager.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
The most important headers: Content-Security-Policy (CSP) prevents XSS by whitelisting trusted sources. X-Frame-Options (or CSP's frame-ancestors) prevents clickjacking. X-Content-Type-Options: nosniff stops MIME-type sniffing attacks.
Input Validation and Sanitization
Validate all user input on the server side — never trust client-side validation alone. The client can be bypassed trivially with browser DevTools or curl. Use a validation library like Zod or Joi:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email().max(255),
username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_]+$/),
age: z.number().int().min(13).max(120),
});
app.post('/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// result.data is now type-safe and validated
const user = await createUser(result.data);
res.status(201).json(user);
});
Environment Variables and Secrets
Hardcoding secrets in source code is one of the most common and costliest mistakes in software. API keys, database passwords, JWT secrets — none of these should ever appear in your codebase. Store them in environment variables and use a .env file locally (listed in .gitignore). In production, use your hosting platform's secrets manager (Vercel environment variables, Railway's secrets, AWS Secrets Manager).
Audit your git history if you have accidentally committed secrets. Use git-secrets as a pre-commit hook to prevent future accidents. Rotate any key that was ever committed immediately — assume it is compromised.
Dependency Security
The npm ecosystem is enormous and not every package is maintained or trustworthy. Run npm audit regularly and before every deployment. High-severity vulnerabilities in direct dependencies should be updated immediately. For transitive dependencies, use npm audit fix or a tool like Snyk for automated pull requests.
Be suspicious of packages with very low download counts, no README, or a name that looks like a typo of a popular package (typosquatting). Check when the package was last published — abandoned packages accumulate unpatched vulnerabilities.
Security Testing
Integrate security into your development process, not as an afterthought. Tools to use: OWASP ZAP for automated scanning of your running app. Semgrep for static analysis — catches patterns like SQL concatenation in your source code. npm audit in your CI pipeline — fail the build on high-severity vulnerabilities. Burp Suite Community Edition for manual testing of authentication flows and API endpoints.
If you maintain a public-facing app, consider setting up a responsible disclosure policy — a security.txt file at /.well-known/security.txt tells security researchers how to report vulnerabilities to you privately.
Incident Response
When (not if) a security incident occurs, having a plan makes the difference between a managed response and a panic. The basics: detect (monitoring, alerts, user reports), contain (take affected systems offline or revoke compromised credentials), assess (understand what was accessed), notify (inform affected users if required by law), remediate (fix the vulnerability), and post-mortem (document what happened and what you changed). Most small teams skip the post-mortem — do not. It is where you prevent recurrence.