
How to Make Web App Secure: A Developer's Guide to Shipping Safe Code in 2025
Key Takeaways (TL;DR)
- The OWASP Top 10 remains the gold standard checklist for web application security — broken access control is now the #1 risk.
- Most breaches exploit well-known, preventable vulnerabilities: injection, misconfiguration, and broken authentication account for the vast majority of incidents.
- AI-generated code from tools like Cursor, Bolt.new, and Lovable often introduces subtle security flaws that pass functional testing but fail security review.
- You don't need a security team to make meaningful progress — parameterized queries, CSP headers, proper auth, and automated scanning cover 80%+ of real-world attack surface.
- Every code example below is production-relevant and shows both the vulnerable and secure pattern side by side.
Why Learning How to Make Web App Secure Matters More Than Ever
In 2023, the average cost of a data breach reached $4.45 million according to IBM's Cost of a Data Breach Report. But you don't have to be an enterprise to be a target. Automated attack bots scan the entire IPv4 address space in under 45 minutes, meaning every deployed web app — including your side project — gets probed within hours of going live.
If you're an indie hacker or a developer shipping fast with AI coding tools, understanding how to make web app secure is non-negotiable. The code these tools generate is often functional but insecure by default. A Stanford study (2022) found that developers using AI coding assistants produced significantly less secure code than those who didn't, and were more likely to believe their code was secure when it wasn't.
This guide walks through the vulnerabilities that actually matter, with real code, real sources, and a prioritized action plan.
The Threat Landscape: What Attackers Actually Exploit
The OWASP Top 10 (2021) reorganized its risk categories based on data from over 500,000 applications. Here's what's most relevant to indie developers:
| OWASP Rank | Category | Prevalence | Indie Dev Risk |
|---|---|---|---|
| A01 | Broken Access Control | 94% of apps tested | Critical — especially with role-based features |
| A02 | Cryptographic Failures | High | High — plaintext secrets, weak hashing |
| A03 | Injection | 94% of apps tested for some form | Critical — SQL, NoSQL, command injection |
| A05 | Security Misconfiguration | 90% of apps tested | Very High — default configs, open CORS |
| A07 | Identification & Auth Failures | High | High — weak session management, no MFA |
Let's go deep on each of these with code you can actually use.
How to Make Web App Secure: Broken Access Control
The Problem
Broken access control (CWE-639: Authorization Bypass Through User-Controlled Key) means users can act outside their intended permissions. This is the single most common vulnerability class in modern web apps. In 2023, the HackerOne Top 10 vulnerability report confirmed that Insecure Direct Object Reference (IDOR) remains one of the most frequently reported bugs in bug bounty programs.
Vulnerable Pattern (Node.js/Express)
// VULNERABLE: No authorization check — any authenticated user can access any invoice
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});Secure Pattern
// SECURE: Verify the resource belongs to the requesting user
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.id,
userId: req.user.id // Scoped to authenticated user
});
if (!invoice) {
return res.status(404).json({ error: 'Invoice not found' });
}
res.json(invoice);
});The fix is simple: always scope database queries to the authenticated user's ID or role. Never trust client-supplied IDs alone. For role-based access, use middleware that checks permissions before the route handler executes, not inside it.
Preventing Injection Attacks
SQL Injection: Still Devastating in 2025
SQL injection (CWE-89) has been on OWASP's list since its inception. The MOVEit Transfer breach (CVE-2023-34362) was a SQL injection vulnerability that compromised data from over 2,600 organizations including the BBC, British Airways, and multiple US government agencies.
AI coding tools frequently generate string-concatenated queries because they're the most common pattern in training data.
Vulnerable Pattern (Python/Flask)
# VULNERABLE: String formatting creates SQL injection vector
@app.route('/users/search')
def search_users():
name = request.args.get('name')
query = f"SELECT * FROM users WHERE name = '{name}'"
result = db.engine.execute(query)
return jsonify([dict(row) for row in result])An attacker sends: /users/search?name=' OR '1'='1' -- and retrieves every user record.
Secure Pattern
# SECURE: Parameterized query with SQLAlchemy
from sqlalchemy import text
@app.route('/users/search')
def search_users():
name = request.args.get('name')
if not name or len(name) > 100:
return jsonify({'error': 'Invalid search parameter'}), 400
result = db.session.execute(
text("SELECT id, name, email FROM users WHERE name = :name"),
{'name': name}
)
return jsonify([dict(row._mapping) for row in result])Key improvements: parameterized query (the database driver handles escaping), input length validation, and explicit column selection instead of SELECT * (which can leak sensitive fields).
NoSQL Injection
If you're using MongoDB, you're not immune. CWE-943 covers improper neutralization of special elements in data query logic.
// VULNERABLE: Accepts operator injection via JSON body
app.post('/api/login', async (req, res) => {
const user = await User.findOne({
email: req.body.email,
password: req.body.password // Attacker sends { "$ne": "" }
});
});
// SECURE: Validate and cast input types explicitly
app.post('/api/login', async (req, res) => {
const email = String(req.body.email);
const password = String(req.body.password);
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Issue session/token
});Cryptographic Failures: Passwords, Secrets, and Transport
The OWASP A02 Cryptographic Failures category covers everything from storing passwords in plaintext to transmitting data over HTTP.
Password Hashing
Use bcrypt, scrypt, or Argon2id. Never use MD5, SHA-1, or SHA-256 alone for password storage. NIST's SP 800-63B Digital Identity Guidelines specifically recommends memory-hard hashing functions.
// VULNERABLE
const crypto = require('crypto');
const hash = crypto.createHash('sha256').update(password).digest('hex');
// No salt, fast hash — crackable in seconds with hashcat
// SECURE
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
const hash = await bcrypt.hash(password, SALT_ROUNDS);
// Adaptive cost factor, built-in salt, memory-hardSecrets Management
Never commit API keys, database credentials, or JWT secrets to your repository. The GitGuardian 2024 State of Secrets Sprawl report found over 12.8 million new secret occurrences in public GitHub commits in a single year. Use environment variables at minimum, and a proper secrets manager (AWS Secrets Manager, Doppler, Infisical) for production.
Security Misconfiguration: The Silent Killer
Security misconfiguration (OWASP A05) is arguably the most common issue in apps built with AI tools because these tools optimize for "it works" not "it's locked down."
HTTP Security Headers
Add these headers to every response. The MDN Content Security Policy documentation provides a comprehensive reference.
// Express.js: Use helmet as a baseline, then customize
const helmet = require('helmet');
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No 'unsafe-inline' — prevents XSS
styleSrc: ["'self'", "'unsafe-inline'"], // Often needed for CSS-in-JS
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
}
}));
// Also critical:
app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true }));
app.disable('x-powered-by');CORS Misconfiguration
Overly permissive CORS is rampant in AI-generated code:
// VULNERABLE: Allows any origin
app.use(cors({ origin: '*', credentials: true })); // This is contradictory and dangerous
// SECURE: Whitelist specific origins
const allowedOrigins = ['https://yourdomain.com', 'https://app.yourdomain.com'];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));Authentication and Session Management Done Right
The OWASP Session Management Cheat Sheet is the definitive reference here. Key rules:
- Set secure cookie attributes:
HttpOnly,Secure,SameSite=Lax(orStrict). - Use short-lived JWTs (15 minutes) with refresh tokens stored in HttpOnly cookies, not localStorage. PortSwigger's JWT attacks guide documents how localStorage-stored tokens enable XSS-based token theft.
- Implement rate limiting on authentication endpoints — credential stuffing attacks are automated and relentless.
- Regenerate session IDs after login to prevent session fixation.
// Secure cookie configuration for Express sessions
app.use(session({
secret: process.env.SESSION_SECRET,
name: '__Host-sid', // __Host- prefix enforces Secure + path=/
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 15 * 60 * 1000, // 15 minutes
domain: undefined // Don't set — __Host- prefix requires this
},
resave: false,
saveUninitialized: false,
store: new RedisStore({ client: redisClient }) // Never use in-memory store in production
}));Cross-Site Scripting (XSS): Still in the Top 5
XSS (CWE-79) allows attackers to execute JavaScript in other users' browsers. Modern frameworks like React and Vue auto-escape output by default, but there are common escape hatches that AI tools love to use:
// VULNERABLE: React's escape hatch — dangerouslySetInnerHTML
function UserComment({ comment }) {
return <div dangerouslySetInnerHTML={{ __html: comment.body }} />;
}
// SECURE: Use a sanitization library if you must render HTML
import DOMPurify from 'dompurify';
function UserComment({ comment }) {
const clean = DOMPurify.sanitize(comment.body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
ALLOWED_ATTR: ['href']
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}Better yet, use a Markdown renderer that doesn't allow raw HTML, such as react-markdown with rehype-sanitize.
Dependency Security: Your Code Is Only as Secure as Your Packages
The Snyk State of Open Source Security report consistently finds that the average JavaScript project has dozens of known vulnerable dependencies. The Log4Shell vulnerability (CVE-2021-44228) demonstrated how a single dependency flaw can cascade across millions of applications.
Actionable steps:
- Run
npm auditoryarn auditas part of your CI pipeline. - Use
npm audit signaturesto verify package provenance. - Pin dependency versions in production with lock files.
- Enable GitHub Dependabot or Snyk for automated vulnerability alerts.
A Practical Security Checklist for Indie Developers
Here's a prioritized checklist you can start working through today, ordered by impact:
- Parameterize all database queries — eliminates injection attacks.
- Add authorization checks to every API endpoint — scope queries to the authenticated user.
- Set HTTP security headers — use
helmetfor Express, or configure your framework's equivalent. - Hash passwords with bcrypt/Argon2id — cost factor of 12+ for bcrypt.
- Configure CORS to specific origins — never use
*with credentials. - Use HttpOnly, Secure, SameSite cookies for sessions.
- Audit dependencies — run
npm auditweekly at minimum. - Enable HTTPS everywhere — use HSTS headers.
- Rate limit authentication endpoints — use
express-rate-limitor equivalent. - Validate and sanitize all user input — on the server, always. Client-side validation is a UX feature, not a security feature.
Automating Security for AI-Generated Code
When you're shipping fast with AI coding tools, manual security review for every generated file isn't realistic. This is where automated scanning becomes essential. Tools like OWASP ZAP provide free, open-source dynamic application security testing.
For a faster feedback loop specifically designed for indie hackers and developers using AI tools, PreBreach runs AI-powered security scans against your deployed app and highlights the exact vulnerabilities that tools like Cursor, Bolt.new, and Lovable commonly introduce — including IDOR, missing headers, and injection flaws.
Actionable Next Steps: Start Today
You don't need to do everything at once. Here's your 30-minute security sprint:
- Right now (5 min): Add
helmetto your Express app or check your framework's security header configuration. Verify headers with SecurityHeaders.com. - Today (10 min): Run
npm auditorpip auditand fix critical vulnerabilities. - Today (15 min): Search your codebase for raw SQL string concatenation,
dangerouslySetInnerHTML, andcors({ origin: '*' }). Fix what you find. - This week: Review every API endpoint for authorization checks. Ask yourself: "Can user A access user B's data by changing an ID in the URL?"
- This week: Run a scan with PreBreach or OWASP ZAP against your staging environment to catch what you missed.
Security isn't a one-time task — it's a development practice. But the good news is that the patterns above cover the vast majority of real-world attacks. Implement them consistently, automate what you can, and you'll be far ahead of most web applications on the internet.