Most security advice on the internet reads like a compliance checklist nobody implements. This guide is different. We’ve audited enough production codebases to know which mistakes show up over and over again — and which “best practices” are actually theater.
If you’re storing passwords with anything other than bcrypt, scrypt, or argon2id, stop reading and fix it now. Then come back.
OWASP Top 10, Prioritized by What Actually Bites
The OWASP Top 10 2021 is a great list, but treating it as a uniform priority queue is a mistake. Here’s the order we see real-world breaches happen:
- Broken Access Control (A01) — by far the most exploited. A logged-in user changes
/api/orders/123to/api/orders/124and gets someone else’s invoice. We see this in roughly 60% of audits. - Injection (A03) — SQL injection is supposedly solved, but it’s still everywhere ORMs aren’t used correctly.
- Cryptographic Failures (A02) — usually means “we stored API tokens in plaintext” or “we used MD5 because the function existed.”
- Identification & Authentication Failures (A07) — homemade JWT logic, missing rate limiting on
/login, no MFA. - Software & Data Integrity Failures (A08) — the supply chain category. This is where npm bites you.
- Security Misconfiguration (A05) — default S3 buckets, debug mode in prod, error messages that leak stack traces.
- Vulnerable Components (A06) — outdated dependencies with known CVEs.
- SSRF (A10) — server fetches a user-supplied URL and accidentally hits
169.254.169.254. - Insecure Design (A04) — architectural problems that no patch fixes.
- Security Logging Failures (A09) — you got breached six months ago and nobody noticed.
If you fix A01 and A03 today, you’ve eliminated the majority of practical attack surface for most web apps. The rest matters, but start here.
Authentication: Stop Building Your Own
This is the single piece of advice that will save you the most pain: do not build your own authentication system in 2025. Use Auth0, Clerk, Supabase Auth, WorkOS, or a battle-tested framework primitive like NextAuth/Lucia.
Authentication is deceptively easy to get 80% right and catastrophically hard to get the last 20%. Session fixation, timing attacks on password comparison, secure cookie flags, account enumeration, MFA bypass via recovery flows, OAuth callback validation — every one of these has bitten teams we respect.
If you absolutely must roll your own, use proper session-based auth with httpOnly, Secure, SameSite=Lax cookies. Not JWTs in localStorage. Never JWTs in localStorage.
// Vulnerable: token reachable from any XSS
localStorage.setItem("token", jwt);
// Better: server-set httpOnly cookie
res.cookie("session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 7,
});
Password hashing? argon2id with sane parameters, or bcrypt cost 12+. Anything else — SHA-256, PBKDF2 with low iterations, “we salted it ourselves” — is wrong.
Authorization: RBAC, ABAC, or ReBAC?
Authentication asks “who are you?” Authorization asks “what can you do?” — and the latter is where most apps quietly fail.
- RBAC (Role-Based) — users have roles, roles have permissions. Right for: most SaaS apps, internal tools, anything where “admin / member / viewer” is roughly the model.
- ABAC (Attribute-Based) — decisions based on attributes (department, time of day, document classification). Right for: healthcare, finance, anything multi-tenant with complex tenant-specific rules.
- ReBAC (Relationship-Based) — modeled after Google Zanzibar. “User X can view document Y because X is in group Z which has access to folder F containing Y.” Right for: collaborative apps, file-sharing, anything with deeply nested ownership. Tools: OpenFGA, SpiceDB, Oso.
Pick one and enforce it server-side on every request. Hiding a button in the UI is not authorization.
# Vulnerable: client-trusted role
@app.route("/admin/users")
def admin_users():
return User.all() # no check
# Fixed: server enforces, denies by default
@app.route("/admin/users")
@require_permission("users:read")
def admin_users():
return User.all()
For service-to-service auth in distributed systems, see our microservices architecture guide — mTLS and short-lived service tokens are the right defaults.
Input Validation: Server-Side, Always, With Schemas
Client-side validation is a UX feature. It is not a security control. The attacker is not running your React app.
Use a schema validator — zod, valibot, joi, pydantic, whatever your stack supports — and validate every request body, query string, and header you read.
// Vulnerable
app.post("/users", (req, res) => {
db.user.create({ data: req.body }); // mass assignment, type confusion, the works
});
// Fixed
const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
role: z.enum(["member", "viewer"]), // admin not selectable
});
app.post("/users", (req, res) => {
const data = CreateUser.parse(req.body);
db.user.create({ data });
});
SQL injection is still in the OWASP Top 10 in 2024 because developers still concatenate strings. Parameterized queries are not optional. Every modern ORM and DB driver supports them. Use them. For deeper coverage on safe query patterns, see our database optimization guide.
// Vulnerable
db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Fixed
db.query("SELECT * FROM users WHERE email = $1", [email]);
XSS and the CSP Header You Should Ship Today
Cross-site scripting is mostly a solved problem if you use a modern framework (React, Vue, Svelte all escape by default) and never call dangerouslySetInnerHTML / v-html with user input. The cases that still bite: server-rendered templates, Markdown rendering without sanitization, and CSP headers that are missing or set to unsafe-inline.
Ship these headers on every response. Tune the CSP for your actual asset hosts:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self' 'nonce-{random}'; img-src 'self' data: https:; connect-src 'self' https://api.yourdomain.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
Yes, nonce-based CSP is more work than unsafe-inline. Do it anyway. web.dev’s CSP guide walks through nonce generation per request.
For CSRF, modern SameSite=Lax cookies handle most cases for free — but if you accept cross-origin POSTs, use double-submit tokens or Origin header checks.
Secrets, Supply Chain, and DDoS
Secrets management. No secrets in .env files committed to git. No API keys in client bundles. Use AWS Secrets Manager, HashiCorp Vault, Doppler, or your platform’s equivalent. Rotate quarterly. Audit access. We cover the operational patterns in our DevOps best practices guide.
Supply chain. A single compromised npm package can own your build server. Defaults that work:
- Run
npm auditin CI and fail on high/critical - Enable Dependabot or Renovate with auto-PR for security patches
- Pin versions with lockfiles and review lockfile diffs
- Generate an SBOM (Syft, CycloneDX) for compliance and audit trails
- Use
npm ci --ignore-scriptsin CI when possible to block postinstall malware
DDoS. You are not going to mitigate a 2 Tbps attack with nginx. Put Cloudflare, AWS Shield, or Fastly in front of your application. Configure rate limiting per IP and per authenticated user. Our cloud migration guide covers edge security in more detail.
Compliance: GDPR, KVKK, and PCI-DSS Without the Theater
If you have European users, GDPR applies. If you have Turkish users, KVKK (Kişisel Verilerin Korunması Kanunu) applies — and it has its own data residency and consent quirks that GDPR compliance does not automatically satisfy. If you touch card data, PCI-DSS applies, even if you “only pass it through.”
The practical baseline:
- Document what personal data you collect and why
- Make data deletion and export endpoints real, not theoretical
- Log access to sensitive data — KVKK and GDPR both expect you to know who accessed what
- For PCI-DSS, the cheapest path is to never touch raw card numbers. Use Stripe Elements, Iyzico, or another tokenizing processor so card data never hits your servers
- Encrypt PII at rest, not just “the database server has full-disk encryption” — encrypt the columns
Compliance is a side effect of good engineering. If your access logs, deletion flows, and encryption are real, the auditor’s job becomes paperwork.
Need a Security Audit?
The hardest part of security is knowing what you don’t know. Most teams discover their authorization is broken only after a customer reports seeing someone else’s data. That’s an expensive way to learn.
We run targeted security audits on web applications — OWASP-aligned, with concrete remediation PRs rather than a 60-page PDF nobody reads. If you want a second set of eyes on your authentication, authorization, or general attack surface before something goes wrong, get in touch. One conversation usually clarifies whether you have a real problem or a healthy paranoia.