...
...
#security #web development #authentication #best practices #cybersecurity

Web Security Best Practices for Developers 2025

Essential web security best practices for 2025. Authentication, authorization, data protection, and defending against common threats.

V
VooStack Team
October 2, 2025
16 min read

Web Application Security: Essential Practices Every Developer Must Know

Security breaches make headlines daily, yet I still review codebases with passwords stored in plain text, SQL queries built with string concatenation, and admin panels protected by nothing but hope. The frustrating part? Most vulnerabilities are completely preventable with basic security practices.

Let’s fix that. This isn’t a theoretical security course—it’s a practical guide to the security measures that actually prevent real attacks.

The Security Mindset

Security isn’t a feature you add at the end. It’s not a checklist item. It’s a mindset shift: never trust user input, and always assume someone is trying to break your system.

Because they are. Automated bots scan for vulnerabilities 24/7. It’s not personal—it’s just profitable.

Authentication: Getting It Right

Authentication determines who users are. Get this wrong, and nothing else matters.

Password Storage (Never Screw This Up)

If you’re storing passwords in plain text, stop reading and fix it now. Seriously.

The Right Way:

const bcrypt = require('bcrypt');

async function hashPassword(password) {
  const saltRounds = 12; // CPU cost factor
  return await bcrypt.hash(password, saltRounds);
}

async function verifyPassword(password, hash) {
  return await bcrypt.compare(password, hash);
}

// Usage
async function registerUser(email, password) {
  const hashedPassword = await hashPassword(password);
  await db.users.create({
    email,
    password: hashedPassword // Store the hash, never the password
  });
}

Why bcrypt?: It’s slow by design. Brute-forcing takes centuries instead of seconds. Argon2 and scrypt are also excellent choices.

Don’t use: MD5, SHA1, or any hash without salting. These are broken and crack in minutes with modern hardware.

Password Requirements That Actually Work

Forget arbitrary rules like “must have 1 uppercase, 1 number, 1 symbol.” They don’t help.

Do this instead:

function validatePassword(password) {
  // Minimum length is the most important factor
  if (password.length < 12) {
    return { valid: false, error: 'Password must be at least 12 characters' };
  }

  // Check against common passwords
  if (isCommonPassword(password)) {
    return { valid: false, error: 'This password is too common' };
  }

  // Optional: Check against haveibeenpwned
  const breached = await checkPwnedPassword(password);
  if (breached) {
    return { valid: false, error: 'This password has been found in data breaches' };
  }

  return { valid: true };
}

Length beats complexity. A 16-character password of random words is stronger than “P@ssw0rd123”.

Multi-Factor Authentication (MFA)

Passwords alone aren’t enough anymore. MFA should be standard, not optional.

TOTP Implementation (Google Authenticator style):

const speakeasy = require('speakeasy');

// Generate secret during MFA setup
function setupMFA(userId) {
  const secret = speakeasy.generateSecret({
    name: `YourApp (${userId})`,
    length: 32
  });

  // Store secret.base32 in database
  await db.users.update(userId, {
    mfaSecret: secret.base32,
    mfaEnabled: false // Not enabled until verified
  });

  // Return QR code for user to scan
  return secret.otpauth_url;
}

// Verify code
function verifyMFACode(secret, token) {
  return speakeasy.totp.verify({
    secret: secret,
    encoding: 'base32',
    token: token,
    window: 2 // Allow 2 time-steps of variance
  });
}

Session Management

Sessions are where authentication often falls apart.

Secure Session Configuration:

const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET, // Long, random, in env vars
  resave: false,
  saveUninitialized: false,
  name: 'sessionId', // Don't use default 'connect.sid'
  cookie: {
    secure: true,      // HTTPS only
    httpOnly: true,    // No JavaScript access
    sameSite: 'strict',// CSRF protection
    maxAge: 3600000    // 1 hour
  }
}));

Session Invalidation:

// Always invalidate on logout
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    res.clearCookie('sessionId');
    res.redirect('/login');
  });
});

// Invalidate all sessions on password change
async function changePassword(userId, newPassword) {
  await updatePassword(userId, newPassword);

  // Invalidate all sessions for this user
  await deleteAllUserSessions(userId);

  // Create new session for current request
  req.session.regenerate(() => {
    req.session.userId = userId;
  });
}

Authorization: Who Can Do What

Authentication tells you who someone is. Authorization determines what they can do.

Role-Based Access Control (RBAC)

// Define permissions
const permissions = {
  admin: ['read', 'write', 'delete', 'manage_users'],
  editor: ['read', 'write'],
  viewer: ['read']
};

// Middleware for permission checks
function requirePermission(permission) {
  return (req, res, next) => {
    const userRole = req.session.user?.role;

    if (!userRole || !permissions[userRole]?.includes(permission)) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    next();
  };
}

// Usage
app.delete('/api/posts/:id', requirePermission('delete'), async (req, res) => {
  // Only users with 'delete' permission reach here
  await deletePost(req.params.id);
  res.json({ success: true });
});

Object-Level Permissions

Role checks aren’t enough. Users should only access their own data:

app.get('/api/orders/:id', authenticateUser, async (req, res) => {
  const order = await db.orders.findOne({ id: req.params.id });

  if (!order) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Check ownership
  if (order.userId !== req.session.userId && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  res.json(order);
});

SQL Injection: The Classic Vulnerability

SQL injection is ancient, yet it’s still in the OWASP Top 10. Why? Because developers keep building queries with string concatenation.

The Vulnerable Way (Never Do This)

// VULNERABLE - DO NOT USE
const userId = req.params.id;
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.query(query); // Attacker sends "1 OR 1=1" and dumps your database

The Safe Way: Parameterized Queries

// Safe - use parameterized queries
const userId = req.params.id;
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId]); // Driver escapes the parameter

// With named parameters (even better)
const query = 'SELECT * FROM users WHERE id = :userId';
db.query(query, { userId });

ORM Protection

// ORMs like Prisma, TypeORM, Sequelize prevent SQL injection by default
const user = await prisma.user.findUnique({
  where: { id: userId } // Automatically parameterized
});

// But you can still screw it up with raw queries
const users = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`; // VULNERABLE

// Safe raw query
const users = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`; // Actually safe - tagged template

Cross-Site Scripting (XSS)

XSS lets attackers inject malicious JavaScript into your pages. It’s devastating: steal sessions, capture keystrokes, deface pages, or redirect to phishing sites.

Types of XSS

Stored XSS: Malicious script saved in database Reflected XSS: Script in URL parameter DOM XSS: Client-side JavaScript vulnerability

Prevention: Output Encoding

Never insert untrusted data directly into HTML:

// VULNERABLE
app.get('/profile', (req, res) => {
  const name = req.query.name;
  res.send(`<h1>Welcome ${name}</h1>`); // XSS vulnerability
});

// Safe - escape HTML
const escapeHtml = (str) => {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
};

app.get('/profile', (req, res) => {
  const name = escapeHtml(req.query.name);
  res.send(`<h1>Welcome ${name}</h1>`);
});

Better: Use Template Engines That Auto-Escape:

// React automatically escapes
function Profile({ name }) {
  return <h1>Welcome {name}</h1>; // Safe - React escapes by default
}

// Unless you explicitly bypass it
function UnsafeProfile({ name }) {
  return <h1 dangerouslySetInnerHTML={{ __html: name }} />; // DANGEROUS
}

Content Security Policy (CSP)

CSP is your last line of defense against XSS:

app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",                    // Only load from same origin
      "script-src 'self' 'unsafe-inline'",     // Scripts from same origin (inline needed for some frameworks)
      "style-src 'self' 'unsafe-inline'",      // Styles from same origin
      "img-src 'self' data: https:",           // Images from self, data URIs, any HTTPS
      "font-src 'self'",                       // Fonts from same origin
      "connect-src 'self' https://api.example.com", // API calls
      "frame-ancestors 'none'",                // Prevent clickjacking
      "base-uri 'self'",                       // Restrict <base> tag
      "form-action 'self'"                     // Only submit forms to same origin
    ].join('; ')
  );
  next();
});

Start strict and loosen as needed. Monitor CSP violations to catch attacks.

Cross-Site Request Forgery (CSRF)

CSRF tricks users into performing unwanted actions while authenticated.

The Attack:

  1. User logs into yourbank.com
  2. User visits attacker.com
  3. attacker.com contains: <img src="https://yourbank.com/transfer?to=attacker&amount=10000" />
  4. Browser sends request with user’s cookies
  5. Money transferred

CSRF Tokens

const csrf = require('csurf');

// Enable CSRF protection
const csrfProtection = csrf({ cookie: true });

app.use(csrfProtection);

// Include token in forms
app.get('/transfer', csrfProtection, (req, res) => {
  res.render('transfer', { csrfToken: req.csrfToken() });
});

// Verify on submission
app.post('/transfer', csrfProtection, (req, res) => {
  // Token verified automatically by middleware
  processTransfer(req.body);
});

HTML Form:

<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>">
  <input name="amount" type="number">
  <button type="submit">Transfer</button>
</form>

SameSite Cookies

Modern browsers support SameSite cookies, which prevent CSRF automatically:

res.cookie('sessionId', sessionId, {
  sameSite: 'strict', // Don't send cookie on cross-site requests
  secure: true,
  httpOnly: true
});

Data Encryption

Data in Transit: HTTPS Always

There’s no excuse for HTTP in 2025. Free certificates from Let’s Encrypt removed all barriers.

// Redirect HTTP to HTTPS
app.use((req, res, next) => {
  if (req.header('x-forwarded-proto') !== 'https') {
    return res.redirect(`https://${req.header('host')}${req.url}`);
  }
  next();
});

// Enforce HTTPS with HSTS
app.use((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains; preload'
  );
  next();
});

Data at Rest: Encrypt Sensitive Fields

const crypto = require('crypto');

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); // 32 bytes

function encrypt(text) {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);

  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  return {
    iv: iv.toString('hex'),
    encryptedData: encrypted,
    authTag: authTag.toString('hex')
  };
}

function decrypt(encrypted) {
  const decipher = crypto.createDecipheriv(
    ALGORITHM,
    KEY,
    Buffer.from(encrypted.iv, 'hex')
  );

  decipher.setAuthTag(Buffer.from(encrypted.authTag, 'hex'));

  let decrypted = decipher.update(encrypted.encryptedData, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

// Usage
async function storeSSN(userId, ssn) {
  const encrypted = encrypt(ssn);
  await db.users.update(userId, {
    ssn_encrypted: encrypted.encryptedData,
    ssn_iv: encrypted.iv,
    ssn_auth_tag: encrypted.authTag
  });
}

API Security

Rate Limiting

Prevent brute force and DoS attacks:

const rateLimit = require('express-rate-limit');

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Max 100 requests per window
  message: 'Too many requests, please try again later'
});

app.use('/api/', apiLimiter);

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // Max 5 login attempts per window
  skipSuccessfulRequests: true // Don't count successful logins
});

app.post('/api/login', authLimiter, async (req, res) => {
  // Login logic
});

Input Validation

Never trust client input:

const Joi = require('joi');

// Define schema
const userSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(12).required(),
  age: Joi.number().integer().min(13).max(120),
  bio: Joi.string().max(500)
});

// Validation middleware
function validate(schema) {
  return (req, res, next) => {
    const { error } = schema.validate(req.body);

    if (error) {
      return res.status(400).json({
        error: 'Validation failed',
        details: error.details.map(d => d.message)
      });
    }

    next();
  };
}

// Usage
app.post('/api/users', validate(userSchema), async (req, res) => {
  // req.body is validated
  const user = await createUser(req.body);
  res.json(user);
});

JWT Security

JWTs are popular but easy to misuse:

const jwt = require('jsonwebtoken');

// Sign tokens
function createToken(userId) {
  return jwt.sign(
    { userId },
    process.env.JWT_SECRET,
    {
      expiresIn: '15m',        // Short-lived
      issuer: 'yourapp.com',
      audience: 'yourapp.com'
    }
  );
}

// Verify tokens
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      issuer: 'yourapp.com',
      audience: 'yourapp.com'
    });

    req.userId = decoded.userId;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

JWT Gotchas:

  • Store secrets in environment variables
  • Use short expiration times
  • Can’t revoke tokens (use refresh tokens)
  • Validate issuer and audience
  • Don’t store sensitive data in payload (it’s base64, not encrypted)

Dependency Security

Your code might be secure, but what about your dependencies?

Automated Scanning

# Check for known vulnerabilities
npm audit

# Fix automatically (be careful)
npm audit fix

# Use Snyk for deeper analysis
npx snyk test

# Scan Docker images
docker scan yourimage:latest

Keep Dependencies Updated

// Use Dependabot or Renovate bot for automatic PRs
// Review and test updates regularly

// Lock dependency versions
// package-lock.json or yarn.lock

Minimize Dependencies

Every dependency is a potential vulnerability:

// Bad - heavyweight library for simple task
const _ = require('lodash');
const firstItem = _.first(array);

// Good - native JavaScript
const firstItem = array[0];

Security Headers

Essential headers that should be on every response:

const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
}));

// Or set manually
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  next();
});

Security Checklist

Before deploying any application:

  • Passwords hashed with bcrypt/Argon2 (cost factor 12+)
  • HTTPS enforced with HSTS header
  • SQL queries use parameterized statements
  • User input validated and sanitized
  • Output encoded based on context
  • CSRF protection enabled
  • Session management secure (httpOnly, secure, sameSite cookies)
  • Rate limiting on authentication endpoints
  • MFA available (required for admin)
  • Security headers configured (CSP, X-Frame-Options, etc.)
  • Dependencies scanned for vulnerabilities
  • Secrets in environment variables (never in code)
  • Error messages don’t leak sensitive info
  • Logging excludes passwords and tokens
  • Regular security audits scheduled

Conclusion

Security isn’t about implementing every possible protection—it’s about understanding the threats and implementing appropriate defenses. Most breaches happen because of basic mistakes: weak passwords, SQL injection, XSS, or outdated dependencies.

Fix the basics first. Then layer on additional security as needed. And remember: security is ongoing, not a one-time task. New vulnerabilities emerge constantly. Stay informed, stay vigilant, and never assume your application is “secure enough.”

Need a security audit for your application? VooStack offers comprehensive security assessments and remediation. Let’s talk about protecting your users.

Topics

security web development authentication best practices cybersecurity
V

Written by VooStack Team

Contact author

Share this article