
How to Protect Web Application from Security Threats: A Developer's Guide with Real Code Examples
How to Protect Web Application from Security Threats
If you're shipping a web application in 2024—especially one built rapidly with AI coding tools like Cursor, Bolt.new, or Lovable—understanding how to protect web application from security threats isn't optional. It's existential. A single SQL injection or exposed API key can destroy user trust overnight, trigger regulatory fines, and sink your product.
The OWASP Top 10 remains the gold standard for understanding web application risks. According to Verizon's 2023 Data Breach Investigations Report, web applications were the vector in 80% of breaches involving hacking. The attack surface is massive, but the defenses are well-documented—if you implement them correctly.
This guide covers the most critical threats, shows you vulnerable vs. secure code patterns, and gives you a concrete checklist to harden your application today.
Key Takeaways (TL;DR)
- Injection attacks (SQL, XSS, command injection) remain the #1 threat class. Use parameterized queries and context-aware output encoding—always.
- Broken authentication causes catastrophic breaches. Implement rate limiting, MFA, and secure session management from day one.
- Security headers like CSP, HSTS, and X-Content-Type-Options are free, high-impact defenses most indie apps miss entirely.
- Dependencies are attack surface. 77% of applications have at least one known vulnerability in their open-source dependencies (Snyk State of Open Source Security 2023).
- Automated scanning catches the low-hanging fruit that attackers look for first. Manual review catches the rest.
The Threat Landscape: What You're Actually Defending Against
Before diving into defenses, you need to understand the threat model. The OWASP Top 10 (2021) categorizes the most critical web application security risks:
| Rank | Category | Real-World Impact |
|---|---|---|
| A01 | Broken Access Control | 94% of applications tested had some form of broken access control |
| A02 | Cryptographic Failures | Exposure of sensitive data due to weak or missing encryption |
| A03 | Injection | SQL injection, XSS, command injection—still devastatingly common |
| A04 | Insecure Design | Flaws in architecture that no amount of code fixes can patch |
| A05 | Security Misconfiguration | Default credentials, open cloud storage, verbose error messages |
| A06 | Vulnerable Components | Log4Shell (CVE-2021-44228) affected millions of applications |
| A07 | Auth Failures | Credential stuffing, weak passwords, broken session management |
How to Protect Web Application from Injection Attacks
Injection flaws occur when untrusted data is sent to an interpreter as part of a command or query. Despite decades of awareness, injection remains in the OWASP Top 10 because developers keep making the same mistakes—especially when generating code quickly with AI assistants.
SQL Injection: Vulnerable vs. Secure Code
The 2023 MOVEit Transfer breach (CVE-2023-34362) was a SQL injection vulnerability that compromised data from hundreds of organizations including the BBC, British Airways, and the US Department of Energy. Here's what vulnerable code looks like and how to fix it:
Vulnerable (Node.js/Express with raw SQL):
// DANGEROUS: Direct string concatenation
app.get('/users', async (req, res) => {
const { search } = req.query;
const result = await db.query(
`SELECT * FROM users WHERE name = '${search}'`
);
res.json(result.rows);
});
// Attacker sends: ?search=' OR '1'='1' --
// Results in: SELECT * FROM users WHERE name = '' OR '1'='1' --'Secure (Parameterized queries):
// SAFE: Parameterized query
app.get('/users', async (req, res) => {
const { search } = req.query;
const result = await db.query(
'SELECT * FROM users WHERE name = $1',
[search]
);
res.json(result.rows);
});
// The database driver escapes the input—injection is impossibleIf you're using an ORM like Prisma or Drizzle, you get parameterized queries by default—but beware of $queryRawUnsafe() or equivalent escape hatches. The OWASP Query Parameterization Cheat Sheet covers patterns for every major language and framework.
Cross-Site Scripting (XSS): The Other Injection
XSS lets attackers inject malicious scripts into pages viewed by other users. According to HackerOne's vulnerability reports, XSS consistently ranks among the top reported vulnerability types on their platform.
Vulnerable (React with dangerouslySetInnerHTML):
// DANGEROUS: Rendering user input as raw HTML
function Comment({ body }) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// If body contains: <img src=x onerror=alert(document.cookie)>
// The script executes in every user's browserSecure (Using DOMPurify for necessary HTML rendering):
import DOMPurify from 'dompurify';
function Comment({ body }) {
// If you MUST render HTML, sanitize it first
const clean = DOMPurify.sanitize(body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: []
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// Better yet: just render as text
function SafeComment({ body }) {
return <div>{body}</div>; // React auto-escapes by default
}React, Vue, and Svelte all auto-escape rendered text by default. The danger comes when you bypass that with raw HTML rendering. The OWASP XSS Prevention Cheat Sheet is the definitive reference.
Hardening Authentication and Session Management
Broken authentication was the direct cause of the Pulse Secure VPN breach (CVE-2019-11510) that compromised numerous enterprise networks. For web applications, the attack surface includes password handling, session tokens, and API authentication.
Secure Password Storage
Never store passwords in plaintext or with fast hashing algorithms like MD5 or SHA-256. Use bcrypt, scrypt, or Argon2id as recommended by OWASP.
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // NIST recommends a cost factor that takes ~250ms
async function registerUser(email, password) {
// Validate password strength first
if (password.length < 8 || password.length > 72) {
throw new Error('Password must be 8-72 characters');
}
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, hashedPassword]
);
}
async function loginUser(email, password) {
const user = await db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (!user.rows[0]) {
// Constant-time comparison to prevent timing attacks
await bcrypt.hash(password, SALT_ROUNDS);
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.rows[0].password_hash);
if (!valid) throw new Error('Invalid credentials');
return user.rows[0];
}Rate Limiting to Prevent Brute Force
Without rate limiting, attackers can try thousands of password combinations per second. Implement rate limiting at both the application and infrastructure level:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window per IP
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
// Use a persistent store in production (Redis)
// store: new RedisStore({ client: redisClient }),
});
app.post('/api/auth/login', loginLimiter, async (req, res) => {
// login logic here
});For production applications, also implement account lockout after repeated failures and consider multi-factor authentication for sensitive operations.
Security Headers: The Highest ROI Defense
Security headers are HTTP response headers that instruct the browser to enable security features. They cost nothing to implement and block entire categories of attacks. According to a scan of the top 1 million websites by Scott Helme, adoption of critical security headers remains surprisingly low.
import helmet from 'helmet';
// Helmet sets sensible defaults for all major security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No inline scripts, no eval
styleSrc: ["'self'", "'unsafe-inline'"], // Ideally use nonces
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));Key headers and what they prevent:
| Header | Prevents | Reference |
|---|---|---|
| Content-Security-Policy | XSS, clickjacking, data injection | MDN CSP Guide |
| Strict-Transport-Security | Protocol downgrade attacks, cookie hijacking | MDN HSTS |
| X-Content-Type-Options | MIME type sniffing attacks | MDN X-Content-Type-Options |
| X-Frame-Options | Clickjacking | MDN X-Frame-Options |
| Permissions-Policy | Unauthorized access to browser APIs (camera, mic, geolocation) | MDN Permissions-Policy |
Protecting Against Broken Access Control
Broken access control jumped to #1 in the OWASP Top 10 (2021), with 94% of tested applications showing some form of this vulnerability. The core issue: your application checks that a user is authenticated but fails to verify they're authorized for the specific resource.
Vulnerable (Insecure Direct Object Reference):
// DANGEROUS: No authorization check
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await db.query(
'SELECT * FROM invoices WHERE id = $1',
[req.params.id]
);
res.json(invoice.rows[0]);
// Any authenticated user can access ANY invoice by guessing IDs
});Secure (Proper authorization):
// SAFE: Authorization check ensures data belongs to the user
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await db.query(
'SELECT * FROM invoices WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!invoice.rows[0]) {
return res.status(404).json({ error: 'Invoice not found' });
}
res.json(invoice.rows[0]);
});This pattern—scoping every database query to the authenticated user—is the single most important access control rule. The OWASP Authorization Cheat Sheet provides comprehensive guidance for complex role-based scenarios.
Securing Your Dependencies
The Log4Shell vulnerability (CVE-2021-44228) demonstrated how a single vulnerable dependency can expose millions of applications. In the JavaScript ecosystem, the event-stream incident showed how supply chain attacks can inject malicious code into popular packages.
Practical steps to secure your dependency chain:
- Audit regularly: Run
npm auditoryarn auditin your CI/CD pipeline. Fail builds on high/critical vulnerabilities. - Pin dependencies: Use
package-lock.jsonoryarn.lockand commit them to version control. - Minimize your attack surface: Every dependency is a liability. Before adding a package, ask: can I write this in 20 lines instead?
- Use tools like GitHub Advisory Database and Snyk for continuous monitoring.
- Enable automatic security updates via Dependabot or Renovate Bot.
# Add to your CI pipeline (GitHub Actions example)
- name: Security Audit
run: npm audit --audit-level=high
# Fails the build if high or critical vulnerabilities existEnvironment and Configuration Security
Security misconfiguration is the #5 risk in the OWASP Top 10, and it's particularly prevalent in applications built with AI coding tools that may scaffold projects with default configurations.
Critical Configuration Checklist
- Never commit secrets: Use environment variables and
.envfiles excluded via.gitignore. Use tools like Gitleaks to scan your repository history. - Disable debug modes in production: Verbose error messages reveal stack traces, database schemas, and internal paths to attackers.
- Set secure cookie attributes:
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true, // Prevents JavaScript access to cookies
secure: true, // Only sent over HTTPS
sameSite: 'lax', // CSRF protection
maxAge: 3600000, // 1 hour expiry
},
resave: false,
saveUninitialized: false,
}));- Validate and sanitize all input on the server: Client-side validation is a UX feature, not a security control. Use libraries like Zod for schema validation on every API endpoint.
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100).trim(),
age: z.number().int().min(13).max(150).optional(),
});
app.post('/api/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 typed and validated
await createUser(result.data);
});Automating Your Security Posture
Manual security reviews are essential but don't scale. According to NIST SP 800-115, security testing should combine automated scanning with manual analysis for comprehensive coverage.
For indie developers and small teams, automated scanning catches the vulnerabilities that attackers scan for first—missing headers, exposed admin panels, injection points, and misconfigured CORS. Tools like PreBreach are purpose-built for this workflow: point it at your application and get actionable findings before an attacker does, without needing a dedicated security team.
A practical testing strategy combines three layers:
- Static analysis (SAST): Scan your source code for vulnerabilities during development. ESLint security plugins and Semgrep rules catch common patterns.
- Dynamic analysis (DAST): Scan your running application from the outside, the way an attacker would. This catches runtime issues that static analysis misses.
- Dependency scanning (SCA): Continuously monitor your dependencies for known vulnerabilities.
A Complete Security Hardening Checklist
Here's a prioritized checklist for protecting your web application from security threats, ordered by impact and effort:
- Use parameterized queries everywhere — eliminates SQL injection (effort: low, impact: critical)
- Deploy security headers with Helmet.js or equivalent — blocks XSS, clickjacking (effort: low, impact: high)
- Validate all input server-side with Zod or similar — prevents injection and logic bugs (effort: medium, impact: critical)
- Implement proper access control — scope every query to the authenticated user (effort: medium, impact: critical)
- Hash passwords with bcrypt/Argon2id — protects credentials if database is compromised (effort: low, impact: high)
- Set secure cookie attributes — httpOnly, secure, sameSite (effort: low, impact: high)
- Enable HTTPS everywhere — prevents traffic interception (effort: low with modern hosting, impact: critical)
- Run automated security scans — catches misconfigurations and common vulnerabilities (effort: low, impact: high)
- Audit dependencies weekly — blocks known supply chain vulnerabilities (effort: low, impact: high)
- Add rate limiting to auth endpoints — prevents brute force attacks (effort: low, impact: medium)
Next Steps: What to Do Today
Securing a web application isn't a one-time event—it's a continuous practice. But you don't need to do everything at once. Here's what to do right now:
- Pick the top 3 items from the checklist above that your application is missing. Implement them today.
- Run
npm audit(or your package manager's equivalent) and fix any high or critical findings. - Add security headers to your application—it takes 5 minutes with Helmet.js and blocks entire attack categories.
- Scan your live application with an automated security scanner to identify what's exposed right now. PreBreach is designed specifically for developers who want fast, actionable results without security expertise.
- Bookmark the OWASP Cheat Sheet Series — it's the best free security reference for developers and covers every topic discussed in this article in greater depth.
The attackers are automated. Your defenses should be too. Every vulnerability you fix before deployment is one fewer breach waiting to happen.