
How to Secure a Web Application: A Developer's Guide with Real Code Examples
Key Takeaways (TL;DR)
- Web application security is a continuous process, not a one-time checklist. The OWASP Top 10 is your essential starting framework.
- Injection flaws (SQL injection, XSS) and broken access control remain the most exploited vulnerability classes year after year.
- Use parameterized queries, output encoding, CSRF tokens, strict CSP headers, and proper authentication as non-negotiable defaults.
- Automated scanning catches the low-hanging fruit. Manual review catches the logic flaws. You need both.
- Every code example in this guide shows a vulnerable pattern and its secure equivalent so you can audit your own codebase.
Why Every Developer Needs to Know How to Secure a Web Application
If you're shipping a web application — whether it's a SaaS MVP, an internal tool, or a side project — you are responsible for its security. The 2024 Verizon Data Breach Investigations Report found that web applications were the vector in over 25% of all breaches. The IBM Cost of a Data Breach Report 2024 puts the average cost of a data breach at $4.88 million globally.
For indie hackers and small teams — especially those leveraging AI coding tools like Cursor, Bolt.new, or Lovable — the stakes are existential. You don't have a dedicated security team. A single SQL injection or exposed API key can destroy user trust overnight. This guide will show you exactly how to secure a web application by walking through the most dangerous vulnerability classes with real, production-relevant code.
The OWASP Top 10: Your Security Baseline
The OWASP Top 10 is the most widely referenced standard for web application security. The 2021 edition (the latest as of this writing) restructured the list based on data from over 500,000 applications. Here are the categories most relevant to indie developers:
| Rank | Category | Real-World Impact |
|---|---|---|
| A01 | Broken Access Control | 94% of apps tested had some form of broken access control (OWASP) |
| A02 | Cryptographic Failures | Exposure of sensitive data due to weak or missing encryption |
| A03 | Injection | SQL injection, XSS, command injection — still in the top 3 after 20 years |
| A05 | Security Misconfiguration | Default credentials, open cloud storage buckets, verbose error messages |
| A07 | Identification and Authentication Failures | Credential stuffing, weak passwords, missing MFA |
Let's go deep on the vulnerabilities you're most likely to encounter and ship.
How to Secure a Web Application Against Injection Attacks
SQL Injection (CWE-89)
SQL injection (CWE-89) occurs when user-supplied data is concatenated directly into SQL queries. It's been exploited in devastating breaches — the 2019 Fortnite vulnerability, the 2008 Heartland Payment Systems breach (130 million cards), and countless smaller incidents catalogued in the CVE database.
Vulnerable code (Node.js with raw SQL):
// DANGEROUS: User input directly concatenated into query
app.get('/user', (req, res) => {
const userId = req.query.id;
const query = `SELECT * FROM users WHERE id = '${userId}'`;
db.query(query, (err, results) => {
res.json(results);
});
});
// Attack: /user?id=' OR '1'='1' --
// Returns ALL users from the databaseSecure code (parameterized query):
// SAFE: Parameterized query — input is treated as data, never as SQL
app.get('/user', (req, res) => {
const userId = req.query.id;
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId], (err, results) => {
res.json(results);
});
});
If you're using an ORM like Prisma or Drizzle, you get parameterized queries by default — but beware of $queryRawUnsafe() or similar escape hatches. The Prisma docs explicitly warn against passing user input into raw queries.
Cross-Site Scripting / XSS (CWE-79)
XSS (CWE-79) lets attackers inject malicious scripts into pages viewed by other users. Stored XSS is particularly dangerous — it persists in your database and executes for every user who views the affected content. The PortSwigger Web Security Academy maintains one of the best references on XSS variants.
Vulnerable code (React with dangerouslySetInnerHTML):
// DANGEROUS: Rendering user-generated HTML without sanitization
function Comment({ body }) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// If body = '<img src=x onerror=alert(document.cookie)>'
// The attacker steals session cookiesSecure code (sanitization with DOMPurify):
import DOMPurify from 'dompurify';
function Comment({ body }) {
// SAFE: DOMPurify strips malicious tags and attributes
const sanitized = DOMPurify.sanitize(body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
ALLOWED_ATTR: ['href'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
Even better: avoid dangerouslySetInnerHTML entirely. React auto-escapes JSX expressions by default. If you must render HTML, DOMPurify is the gold standard, used by major organizations including Google and Mozilla.
Securing Authentication and Session Management
Broken authentication (OWASP A07) is how attackers take over user accounts. The NIST SP 800-63B Digital Identity Guidelines provide the authoritative framework for authentication security.
Password Hashing
Never store passwords in plaintext or with weak hashes like MD5 or SHA-1. Use bcrypt, scrypt, or Argon2id (recommended by OWASP's Password Storage Cheat Sheet).
Vulnerable code:
const crypto = require('crypto');
// DANGEROUS: SHA-256 without salt — fast to brute-force with GPU
const hash = crypto.createHash('sha256').update(password).digest('hex');
Secure code:
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Adjust for your performance requirements
// Hashing on signup
const hash = await bcrypt.hash(password, SALT_ROUNDS);
// Verifying on login
const isValid = await bcrypt.compare(submittedPassword, storedHash);
Session Security
Configure your session cookies properly. Every one of these flags matters:
app.use(session({
secret: process.env.SESSION_SECRET, // Long, random, from env
name: '__Host-sid', // Cookie prefix for extra protection
cookie: {
httpOnly: true, // Prevents JavaScript access (blocks XSS theft)
secure: true, // Only sent over HTTPS
sameSite: 'lax', // Mitigates CSRF
maxAge: 3600000, // 1 hour — don't keep sessions alive forever
},
resave: false,
saveUninitialized: false,
}));
The MDN Web Docs on HTTP cookies remain the best reference for understanding each cookie attribute.
How to Secure a Web Application with HTTP Security Headers
Security headers are one of the highest-impact, lowest-effort defenses you can add. Many AI-generated scaffolds ship with zero security headers.
Essential Headers
// Using helmet.js in Express — sets 15+ security headers in one line
const helmet = require('helmet');
app.use(helmet());
// Or configure manually for fine-grained control:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No inline scripts, no CDN wildcards
styleSrc: ["'self'", "'unsafe-inline'"], // Ideally use nonces
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameSrc: ["'none'"], // Clickjacking prevention
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
}));
The OWASP HTTP Headers Cheat Sheet provides a complete reference. You can test your headers at securityheaders.com.
| Header | What It Prevents | Priority |
|---|---|---|
| Content-Security-Policy | XSS, data injection, clickjacking | Critical |
| Strict-Transport-Security | Protocol downgrade attacks, cookie hijacking | Critical |
| X-Content-Type-Options | MIME-type sniffing attacks | High |
| X-Frame-Options | Clickjacking | High |
| Referrer-Policy | Information leakage via referrer headers | Medium |
| Permissions-Policy | Unauthorized access to browser APIs (camera, mic, geolocation) | Medium |
Broken Access Control: The #1 Risk
Broken access control has been the number one risk in the OWASP Top 10 since the 2021 edition. It means users can act outside their intended permissions — accessing other users' data, elevating privileges, or modifying resources they shouldn't.
Vulnerable code (Insecure Direct Object Reference / IDOR):
// DANGEROUS: No authorization check — any authenticated user
// can access any other user's invoices by changing the ID
app.get('/api/invoices/:invoiceId', authenticate, async (req, res) => {
const invoice = await Invoice.findById(req.params.invoiceId);
res.json(invoice);
});
Secure code:
// SAFE: Verify the requesting user owns the resource
app.get('/api/invoices/:invoiceId', authenticate, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.invoiceId,
userId: req.user.id, // Scoped to the authenticated user
});
if (!invoice) {
return res.status(404).json({ error: 'Invoice not found' });
// Return 404, not 403 — don't reveal that the resource exists
}
res.json(invoice);
});
IDOR vulnerabilities are extremely common in bug bounty programs. HackerOne reports consistently show IDOR as one of the most frequently reported vulnerability types, with payouts reaching tens of thousands of dollars.
Cross-Site Request Forgery (CSRF)
CSRF (CWE-352) tricks authenticated users into executing unwanted actions. While modern SameSite cookie defaults have reduced the risk, CSRF protection is still essential for state-changing operations, especially if you support older browsers or use SameSite=None.
For traditional form-based apps:
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// Token is automatically validated. Invalid tokens = 403.
processTransfer(req.body);
});
For SPAs with API backends: Use the double-submit cookie pattern or custom request headers. If your API only accepts Content-Type: application/json and validates it server-side, you already have partial CSRF protection since HTML forms cannot submit JSON.
Security Misconfiguration: The Silent Killer
AI coding tools can generate entire backends in minutes, but they often leave behind dangerous defaults:
- Debug mode in production: Stack traces expose file paths, dependency versions, and database schemas.
- Default credentials: Admin panels with
admin/adminor exposed database ports with no password. - Verbose error messages: Returning raw error objects to the client.
- Unnecessary ports and services: Development servers, database admin panels exposed to the internet.
Secure error handling pattern:
// SAFE: Generic error response for production, detailed logging server-side
app.use((err, req, res, next) => {
// Log the full error for your own debugging
console.error(err.stack);
// Never send internal details to the client
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: statusCode === 500
? 'An internal error occurred'
: err.message,
});
});
The OWASP Error Handling Cheat Sheet provides more detailed guidance on balancing useful error messages with security.
Dependency Vulnerabilities: Your Biggest Blind Spot
A typical Node.js project pulls in hundreds of transitive dependencies. The Snyk State of Open Source Security Report found that 84% of codebases contain at least one known vulnerability in their dependencies.
Real-world examples are sobering: the event-stream incident (CVE-2018-16396) injected cryptocurrency-stealing code into a popular npm package. The Log4Shell vulnerability (CVE-2021-44228) affected virtually every Java application on the planet.
Automate dependency auditing:
# npm built-in audit
npm audit
# Fix automatically where possible
npm audit fix
# Or use Snyk for deeper analysis
npx snyk test
Run npm audit in your CI/CD pipeline and fail the build on high-severity vulnerabilities. It's free and takes seconds.
Rate Limiting and Input Validation
Every endpoint that accepts user input needs both rate limiting (to prevent brute-force and denial-of-service attacks) and input validation (to reject malformed data before it reaches your business logic).
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
// Rate limiting on login endpoint
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window
message: { error: 'Too many login attempts. Try again later.' },
standardHeaders: true,
});
// Input validation schema
const loginSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(128),
});
app.post('/api/login', loginLimiter, async (req, res) => {
const result = loginSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid input' });
}
// Proceed with authentication using result.data
});
Zod, Joi, and Yup are excellent validation libraries. The key principle: never trust client-side validation alone. It can be bypassed with a single cURL command.
A Practical Security Checklist
Here's a condensed checklist you can apply to any web application today. It's ordered by impact:
- Use parameterized queries for all database interactions — no exceptions.
- Hash passwords with bcrypt/Argon2id with a work factor of at least 10-12.
- Set HttpOnly, Secure, and SameSite flags on all authentication cookies.
- Add security headers using
helmetor your framework's equivalent. - Implement authorization checks on every API endpoint — not just authentication.
- Validate all input server-side with a schema validation library.
- Run
npm audit(or equivalent) in your CI pipeline. - Rate-limit authentication endpoints and other sensitive routes.
- Never expose stack traces or internal errors to end users.
- Scan your app regularly with automated tools. A tool like PreBreach is built specifically for indie developers who need fast, actionable vulnerability scanning without enterprise complexity.
Actionable Next Steps
You don't need to implement everything at once. Here's what to do in the next hour:
- Run
npm auditright now. Fix any critical or high-severity findings. - Check your security headers at securityheaders.com. Add
helmet()if you're using Express. - Search your codebase for string concatenation in database queries. Replace with parameterized queries.
- Search for
dangerouslySetInnerHTML(React) orv-html(Vue). Ensure every instance uses DOMPurify or is rendering trusted content only. - Verify your login endpoint has rate limiting and your cookies have
httpOnlyandsecureflags. - Run an automated scan against your staging environment using PreBreach or another scanner to catch what you've missed.
Security isn't a feature you ship once — it's a practice you maintain. The good news: the steps above will protect you against the vast majority of real-world attacks. Start with the basics, build good habits, and iterate. Your users are trusting you with their data. That trust is worth protecting.