← All posts

Application Security and OWASP Best Practices: A Production Engineer's Guide

Application Security and OWASP Best Practices: A Production Engineer's Guide

I've been the engineer who got paged at 2 AM because someone discovered we were leaking user data through an API endpoint. I've also been the one who had to explain to leadership why our authentication system needed a complete rewrite. Security isn't theoretical — it's the difference between a successful product and a catastrophic breach. The OWASP Top 10 isn't just a checklist; it's a battle-tested playbook built from real-world failures. Let's talk about the vulnerabilities I've actually seen in production and how to prevent them.

Authentication: JWT Tokens Done Wrong

The most common security issue I've encountered isn't some exotic attack — it's basic authentication misconfigurations. I've seen production systems that stored JWT secrets in environment variables that were committed to Git, tokens that never expired, and refresh token implementations that defeated the entire purpose of access tokens. Here's how to implement JWT authentication properly:

// BAD: Weak secret, no expiration, no refresh strategy
const token = jwt.sign({ userId: user.id }, 'secret123');

// GOOD: Strong secret, short-lived access tokens, refresh tokens
const generateTokens = (user) => {
  const accessToken = jwt.sign(
    { userId: user.id, type: 'access' },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }
  );
  
  const refreshToken = jwt.sign(
    { userId: user.id, type: 'refresh', tokenVersion: user.tokenVersion },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d', algorithm: 'HS256' }
  );
  
  return { accessToken, refreshToken };
};

// Store refresh token hash in database, not the token itself
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
await db.refreshTokens.create({
  userId: user.id,
  tokenHash: hashedRefreshToken,
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
Security Reality Check: If your JWT secret is shorter than 256 bits or you're using symmetric keys in a distributed system where multiple services need to verify tokens, you're doing it wrong. Use asymmetric RS256 for multi-service architectures and rotate your keys regularly.

Injection Attacks: It's Not Just SQL Anymore

SQL injection is the classic example everyone knows, but I've seen injection vulnerabilities in NoSQL queries, LDAP filters, OS commands, and even GraphQL. The fundamental mistake is always the same: trusting user input. Modern ORMs help with SQL injection, but they don't solve the entire problem. Here's a real example from a codebase I inherited that was vulnerable to NoSQL injection:

// VULNERABLE: NoSQL injection in MongoDB
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;
  // If attacker sends: { "username": {"$ne": null}, "password": {"$ne": null} }
  // This bypasses authentication!
  const user = await User.findOne({ username, password });
  if (user) return res.json({ token: generateToken(user) });
});

// SECURE: Input validation and parameterization
const Joi = require('joi');

const loginSchema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: Joi.string().min(8).required()
});

app.post('/api/login', async (req, res) => {
  const { error, value } = loginSchema.validate(req.body);
  if (error) return res.status(400).json({ error: error.details[0].message });
  
  const { username, password } = value;
  const user = await User.findOne({ username: username.toString() });
  
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  return res.json({ token: generateToken(user) });
});

Authorization: The Forgotten Layer

Authentication tells you who the user is. Authorization tells you what they can do. I've seen countless applications that implement authentication correctly but completely botch authorization. The classic mistake is checking if a user is logged in but not verifying they have permission to access the specific resource. This leads to Insecure Direct Object Reference (IDOR) vulnerabilities where User A can access User B's data just by changing an ID in the URL.

// VULNERABLE: Only checks authentication, not authorization
app.get('/api/orders/:orderId', authenticateToken, async (req, res) => {
  const order = await Order.findById(req.params.orderId);
  return res.json(order); // Any logged-in user can see any order!
});

// SECURE: Verify ownership or permission
app.get('/api/orders/:orderId', authenticateToken, async (req, res) => {
  const order = await Order.findById(req.params.orderId);
  
  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }
  
  // Check ownership
  if (order.userId !== req.user.id && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  return res.json(order);
});

// BETTER: Implement middleware for reusable authorization
const authorizeResource = (resourceType) => async (req, res, next) => {
  const resource = await resourceType.findById(req.params.id);
  
  if (!resource) return res.status(404).json({ error: 'Not found' });
  if (resource.userId !== req.user.id && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  req.resource = resource;
  next();
};

app.get('/api/orders/:id', authenticateToken, authorizeResource(Order), (req, res) => {
  return res.json(req.resource);
});

Security Headers: The Low-Hanging Fruit

Security headers are one of the easiest wins in application security, yet I constantly see production apps missing them. These headers protect against clickjacking, XSS, and other client-side attacks. Use the helmet middleware in Express or configure them in your reverse proxy. Here's my standard configuration:

  • Content-Security-Policy: Prevents XSS by controlling which resources can be loaded. Start strict and loosen as needed.
  • X-Frame-Options: Prevents clickjacking by controlling whether your site can be embedded in iframes.
  • Strict-Transport-Security: Forces HTTPS connections. Set max-age to at least 1 year in production.
  • X-Content-Type-Options: Prevents MIME-sniffing attacks. Always set to 'nosniff'.
  • Referrer-Policy: Controls how much referrer information is sent. Use 'strict-origin-when-cross-origin'.
Production Tip: Don't just set these headers and forget them. Use securityheaders.com to audit your configuration regularly. I've caught misconfigurations in production that were introduced by well-meaning developers who didn't understand the implications of their changes.

Rate Limiting and API Abuse

Rate limiting isn't just about preventing DDoS attacks — it's about protecting your resources from abuse and credential stuffing. I implemented rate limiting after watching our login endpoint get hammered with 10,000 requests per minute during a credential stuffing attack. The key is implementing multiple layers: per-IP limits, per-user limits, and per-endpoint limits. Use Redis for distributed rate limiting if you're running multiple server instances.

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

// Aggressive rate limiting for authentication endpoints
const authLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:auth:'
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  message: 'Too many login attempts, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
  // Implement progressive delays
  skipSuccessfulRequests: true,
});

// General API rate limiting
const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:api:'
  }),
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  message: 'Too many requests, please slow down'
});

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

Security isn't a feature you add at the end — it's a mindset you adopt from day one. The OWASP Top 10 exists because these vulnerabilities keep appearing in production systems, often built by smart engineers who simply didn't know better. Implement these practices in your codebase today, not after you get breached. Your future self (and your users) will thank you.