Published on

Web Security Best Practices Every Developer Must Know

Authors

Introduction

Security vulnerabilities can destroy your app, your users' data, and your reputation overnight. In 2026, with AI-powered attacks growing more sophisticated, secure coding is a non-negotiable skill.

This guide covers the OWASP Top 10 threats and exactly how to prevent them.

1. SQL Injection — The Most Dangerous Vulnerability

SQL injection happens when user input is directly embedded into SQL queries:

// ❌ VULNERABLE — Never do this!
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`
// If email = "'; DROP TABLE users; --"  → disaster!

// ✅ SAFE — Use parameterized queries
const user = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]  // Input is safely escaped
)

// ✅ SAFE with ORM (Prisma/Sequelize/TypeORM)
const user = await prisma.user.findUnique({
  where: { email: req.body.email }  // Automatically safe
})

Rule: Never concatenate user input into SQL. Always use parameterized queries or an ORM.

2. Cross-Site Scripting (XSS)

XSS occurs when malicious scripts are injected into web pages viewed by others:

// ❌ VULNERABLE — Renders user input as HTML
element.innerHTML = userInput
document.write(userInput)

// ✅ SAFE — Text only, no HTML interpretation
element.textContent = userInput

// ✅ SAFE in React — JSX escapes by default
return <div>{userInput}</div>  // Safe!
return <div dangerouslySetInnerHTML={{ __html: userInput }} />  // ❌ Dangerous!

Content Security Policy (CSP) adds another layer of defense:

// In Express
import helmet from 'helmet'

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'nonce-{RANDOM}'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
    }
  }
}))

3. Authentication and Password Storage

import bcrypt from 'bcryptjs'

// ❌ NEVER store plain text passwords!
const user = { password: req.body.password }  // Terrible!

// ✅ Always hash passwords with bcrypt
const SALT_ROUNDS = 12

async function register(email, password) {
  const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
  await db.user.create({ email, password: hashedPassword })
}

async function login(email, password) {
  const user = await db.user.findByEmail(email)
  if (!user) return null  // Don't reveal if user exists!

  const isValid = await bcrypt.compare(password, user.password)
  if (!isValid) return null

  return generateJWT(user)
}

JWT Best Practices:

import jwt from 'jsonwebtoken'

// ❌ INSECURE — Weak secret
const token = jwt.sign({ userId: 1 }, 'secret')

// ✅ SECURE
const token = jwt.sign(
  { userId: user.id, email: user.email },
  process.env.JWT_SECRET,  // Long, random, from env
  {
    expiresIn: '15m',   // Short-lived access token
    issuer: 'my-api',
    audience: 'my-client',
  }
)

4. CSRF (Cross-Site Request Forgery)

CSRF tricks users into performing actions they didn't intend:

// Protection with CSRF tokens
import csrf from 'csurf'

app.use(csrf({ cookie: true }))

app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() })
})

// In your form HTML
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">

For APIs using JWT in Authorization headers, CSRF is not a concern — only cookie-based auth needs CSRF protection.

5. Input Validation

Never trust user input — always validate on the server:

import { z } from 'zod'

const RegisterSchema = z.object({
  email: z.string().email().max(255),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase')
    .regex(/[0-9]/, 'Must contain number'),
  name: z.string().min(1).max(50).trim(),
})

app.post('/register', async (req, res) => {
  const result = RegisterSchema.safeParse(req.body)

  if (!result.success) {
    return res.status(400).json({
      errors: result.error.flatten().fieldErrors
    })
  }

  await createUser(result.data)
})

6. Rate Limiting — Prevent Brute Force

import rateLimit from 'express-rate-limit'

// Strict limit for auth routes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 10,                    // Max 10 attempts
  message: 'Too many login attempts. Try again in 15 minutes.',
  standardHeaders: true,
})

// General API limit
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: 100,
})

app.use('/api/auth', authLimiter)
app.use('/api/', apiLimiter)

7. Secure HTTP Headers with Helmet

import helmet from 'helmet'

// Helmet sets a dozen security headers automatically
app.use(helmet())

// What Helmet sets:
// X-Frame-Options: DENY (prevents clickjacking)
// X-Content-Type-Options: nosniff (prevents MIME sniffing)
// Strict-Transport-Security (forces HTTPS)
// X-XSS-Protection: 0 (modern browsers don't need this)
// Referrer-Policy: no-referrer
// And more...

8. Secrets Management

// ❌ Never hardcode secrets in code
const SECRET = 'my-super-secret-key-123'

// ❌ Never commit .env files to Git

// ✅ Use environment variables
const SECRET = process.env.JWT_SECRET

// ✅ Validate required env vars on startup
const requiredEnvVars = ['JWT_SECRET', 'DATABASE_URL', 'REDIS_URL']
requiredEnvVars.forEach(varName => {
  if (!process.env[varName]) {
    throw new Error(`Missing required env variable: ${varName}`)
  }
})

Security Checklist

Before deploying any app, verify:

  • ✅ Parameterized queries (no SQL injection)
  • ✅ Passwords hashed with bcrypt (cost factor ≥ 10)
  • ✅ HTTPS everywhere (use Let's Encrypt)
  • ✅ Helmet.js for security headers
  • ✅ Input validation on all endpoints
  • ✅ Rate limiting on auth routes
  • ✅ Secrets in environment variables, never in code
  • ✅ Dependencies audited (npm audit)
  • ✅ Error messages don't reveal internal details
  • ✅ JWT expiry is short (15min access, 7d refresh)

Conclusion

Security isn't a feature you add at the end — it's a discipline you practice from day one. Use parameterized queries, hash passwords, validate all input, add rate limiting, and scan your dependencies regularly. One security breach can undo years of work. The cost of prevention is minimal compared to the cost of a breach.