Security bugs are expensive. A single SQL injection can expose millions of user records. An XSS vulnerability can compromise user sessions. Let's skip the theory and dive into practical, actionable security practices with real code examples you can use today.
1. Authentication: Don't Roll Your Own Crypto
❌ Bad: Plain Text Passwords
# NEVER DO THIS
def create_user(username, password):
db.execute("INSERT INTO users (username, password) VALUES (?, ?)",
username, password)
✅ Good: Hashed Passwords with bcrypt
import bcrypt
def create_user(username, password):
# Generate salt and hash password
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
db.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)",
username, hashed)
def verify_password(username, password):
user = db.execute("SELECT password_hash FROM users WHERE username = ?",
username).fetchone()
if user and bcrypt.checkpw(password.encode('utf-8'), user['password_hash']):
return True
return False
Node.js Example:
const bcrypt = require('bcrypt');
async function createUser(username, password) {
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
await db.query(
'INSERT INTO users (username, password_hash) VALUES ($1, $2)',
[username, hashedPassword]
);
}
async function verifyPassword(username, password) {
const result = await db.query(
'SELECT password_hash FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) return false;
return await bcrypt.compare(password, result.rows[0].password_hash);
}
2. SQL Injection: The Classic Vulnerability
❌ Bad: String Concatenation
# VULNERABLE TO SQL INJECTION
def get_user(username):
query = "SELECT * FROM users WHERE username = '" + username + "'"
return db.execute(query).fetchone()
# Attacker inputs: admin' OR '1'='1
# Resulting query: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
# Returns all users!
✅ Good: Parameterized Queries
def get_user(username):
query = "SELECT * FROM users WHERE username = ?"
return db.execute(query, (username,)).fetchone()
# Even with malicious input, it's treated as a literal string
Node.js with PostgreSQL:
// ❌ VULNERABLE
async function getUser(username) {
const query = `SELECT * FROM users WHERE username = '${username}'`;
return await db.query(query);
}
// ✅ SAFE - Parameterized query
async function getUser(username) {
const query = 'SELECT * FROM users WHERE username = $1';
return await db.query(query, [username]);
}
ORM Examples (Even Safer):
# Using SQLAlchemy
from sqlalchemy import select
def get_user(username):
stmt = select(User).where(User.username == username)
return session.execute(stmt).scalar_one_or_none()
// Using Sequelize
async function getUser(username) {
return await User.findOne({
where: { username: username }
});
}
3. XSS Prevention: Escape User Input
❌ Bad: Direct HTML Rendering
// VULNERABLE TO XSS
function displayComment(comment) {
document.getElementById('comments').innerHTML += `
<div class="comment">
<p>${comment.text}</p>
<span>By: ${comment.author}</span>
</div>
`;
}
// If comment.text = '<script>alert(document.cookie)</script>'
// The script executes!
✅ Good: Sanitize and Escape
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function displayComment(comment) {
const div = document.createElement('div');
div.className = 'comment';
const p = document.createElement('p');
p.textContent = comment.text; // textContent auto-escapes
const span = document.createElement('span');
span.textContent = `By: ${comment.author}`;
div.appendChild(p);
div.appendChild(span);
document.getElementById('comments').appendChild(div);
}
React (Auto-Escapes by Default):
// ✅ Safe by default
function Comment({ comment }) {
return (
<div className="comment">
<p>{comment.text}</p>
<span>By: {comment.author}</span>
</div>
);
}
// ❌ Dangerous - only use when absolutely necessary
function RawComment({ htmlContent }) {
return (
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
);
}
Backend Sanitization (Python):
import bleach
ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'a']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title']}
def sanitize_html(dirty_html):
return bleach.clean(
dirty_html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True
)
def create_post(title, content):
clean_content = sanitize_html(content)
db.execute(
"INSERT INTO posts (title, content) VALUES (?, ?)",
(title, clean_content)
)
4. Secure API Authentication with JWT
✅ Proper JWT Implementation:
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Store this in environment variables, NOT in code
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
const JWT_EXPIRY = '1h';
function generateToken(userId) {
return jwt.sign(
{ userId: userId },
JWT_SECRET,
{
expiresIn: JWT_EXPIRY,
algorithm: 'HS256'
}
);
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
} catch (error) {
return null;
}
}
// Middleware for protected routes
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.userId = decoded.userId;
next();
}
// Usage
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await verifyUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateToken(user.id);
res.json({ token });
});
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({ message: 'Access granted', userId: req.userId });
});
5. Secure Session Management
✅ Express.js Secure Sessions:
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const redisClient = redis.createClient();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only send over HTTPS
httpOnly: true, // Not accessible via JavaScript
maxAge: 1000 * 60 * 60, // 1 hour
sameSite: 'strict' // CSRF protection
}
}));
// Login handler
app.post('/login', async (req, res) => {
const user = await verifyUser(req.body.username, req.body.password);
if (user) {
// Regenerate session ID to prevent session fixation
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Login failed' });
req.session.userId = user.id;
req.session.username = user.username;
res.json({ success: true });
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Logout handler
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
6. Input Validation: Never Trust User Input
✅ Comprehensive Validation (Node.js with express-validator):
const { body, validationResult } = require('express-validator');
app.post('/api/users',
// Validation rules
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Invalid email address'),
body('username')
.isLength({ min: 3, max: 20 })
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('Username must be 3-20 alphanumeric characters'),
body('age')
.optional()
.isInt({ min: 13, max: 120 })
.withMessage('Age must be between 13 and 120'),
body('website')
.optional()
.isURL()
.withMessage('Invalid URL'),
// Handler
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Safe to use validated data
const { email, username, age, website } = req.body;
// ... create user
}
);
Python with Pydantic:
from pydantic import BaseModel, EmailStr, HttpUrl, validator
from fastapi import FastAPI, HTTPException
app = FastAPI()
class UserCreate(BaseModel):
email: EmailStr
username: str
age: int | None = None
website: HttpUrl | None = None
@validator('username')
def validate_username(cls, v):
if not 3 <= len(v) <= 20:
raise ValueError('Username must be 3-20 characters')
if not v.replace('_', '').isalnum():
raise ValueError('Username must be alphanumeric')
return v
@validator('age')
def validate_age(cls, v):
if v is not None and not 13 <= v <= 120:
raise ValueError('Age must be between 13 and 120')
return v
@app.post("/api/users")
async def create_user(user: UserCreate):
# Data is automatically validated
# ... create user
return {"message": "User created", "username": user.username}
7. CSRF Protection
✅ CSRF Tokens (Express):
const csrf = require('csurf');
// Setup CSRF protection
const csrfProtection = csrf({ cookie: true });
app.use(cookieParser());
// Send CSRF token to client
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
// Validate CSRF token on POST
app.post('/process', csrfProtection, (req, res) => {
// If we get here, CSRF token is valid
res.json({ success: true });
});
HTML Form:
<form action="/process" method="POST">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="text" name="data">
<button type="submit">Submit</button>
</form>
Fetch API with CSRF:
async function submitForm(data) {
const csrfToken = document.querySelector('[name=_csrf]').value;
const response = await fetch('/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken
},
body: JSON.stringify(data)
});
return response.json();
}
8. Secure File Uploads
✅ Safe File Upload Handler:
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
// Allowed file types
const ALLOWED_TYPES = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'application/pdf': 'pdf'
};
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// Generate random filename to prevent path traversal
const randomName = crypto.randomBytes(16).toString('hex');
const ext = ALLOWED_TYPES[file.mimetype];
cb(null, `${randomName}.${ext}`);
}
});
const upload = multer({
storage: storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
// Check MIME type
if (!ALLOWED_TYPES[file.mimetype]) {
return cb(new Error('Invalid file type'), false);
}
cb(null, true);
}
});
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Additional validation: check actual file content (magic bytes)
const fileBuffer = fs.readFileSync(req.file.path);
const fileType = require('file-type').fromBuffer(fileBuffer);
if (!fileType || !ALLOWED_TYPES[fileType.mime]) {
fs.unlinkSync(req.file.path); // Delete invalid file
return res.status(400).json({ error: 'Invalid file content' });
}
res.json({
message: 'File uploaded successfully',
filename: req.file.filename
});
});
9. API Rate Limiting
✅ Rate Limiting Middleware:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');
const redisClient = redis.createClient();
// General API rate limit
const apiLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:api:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per windowMs
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false
});
// Stricter limit for authentication endpoints
const authLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:auth:'
}),
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 minutes
skipSuccessfulRequests: true, // Don't count successful logins
message: 'Too many login attempts, please try again later'
});
app.use('/api/', apiLimiter);
app.use('/api/login', authLimiter);
app.use('/api/register', authLimiter);
10. Secrets Management: Never Hardcode Credentials
❌ Bad: Hardcoded Secrets
// NEVER DO THIS
const API_KEY = 'sk_live_51H7x2y3z4a5b6c7d8e9f0';
const DB_PASSWORD = 'mySecretPassword123';
✅ Good: Environment Variables
// .env file (add to .gitignore!)
/*
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
API_KEY=sk_live_51H7x2y3z4a5b6c7d8e9f0
JWT_SECRET=your-super-secret-jwt-key
SESSION_SECRET=your-session-secret
*/
// Load environment variables
require('dotenv').config();
const config = {
database: process.env.DATABASE_URL,
apiKey: process.env.API_KEY,
jwtSecret: process.env.JWT_SECRET,
sessionSecret: process.env.SESSION_SECRET
};
// Validate required env vars on startup
const requiredEnvVars = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
}
Python Example:
import os
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv('DATABASE_URL')
API_KEY = os.getenv('API_KEY')
SECRET_KEY = os.getenv('SECRET_KEY')
# Validate
if not all([DATABASE_URL, API_KEY, SECRET_KEY]):
raise ValueError("Missing required environment variables")
11. Security Headers
✅ Essential Security Headers:
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
// Or manually
app.use((req, res, next) => {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Enable XSS filter
res.setHeader('X-XSS-Protection', '1; mode=block');
// Referrer policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
next();
});
12. Secure Database Queries: Beyond SQL Injection
✅ Principle of Least Privilege:
-- Create a read-only user for reporting
CREATE USER 'reports'@'localhost' IDENTIFIED BY 'secure_password';
GRANT SELECT ON mydb.* TO 'reports'@'localhost';
-- Create an app user with limited permissions
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'secure_password';
GRANT SELECT, INSERT, UPDATE ON mydb.users TO 'appuser'@'localhost';
GRANT SELECT, INSERT ON mydb.posts TO 'appuser'@'localhost';
-- Never use root/admin for application connections
Connection String Example:
// Different connection pools for different purposes
const readOnlyPool = mysql.createPool({
host: process.env.DB_HOST,
user: 'reports',
password: process.env.DB_REPORTS_PASSWORD,
database: process.env.DB_NAME
});
const appPool = mysql.createPool({
host: process.env.DB_HOST,
user: 'appuser',
password: process.env.DB_APP_PASSWORD,
database: process.env.DB_NAME
});
13. Dependency Scanning in CI/CD
✅ GitHub Actions Security Workflow:
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
Package.json Scripts:
{
"scripts": {
"security-check": "npm audit && npm outdated",
"security-fix": "npm audit fix",
"precommit": "npm run security-check"
}
}
14. Logging Security Events (Without Logging Sensitive Data)
✅ Secure Logging:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'security.log' })
]
});
// Log security events
function logSecurityEvent(event, details) {
logger.info({
type: 'security',
event: event,
timestamp: new Date().toISOString(),
ip: details.ip,
userId: details.userId,
userAgent: details.userAgent,
// NEVER log passwords, tokens, or sensitive data
});
}
// Usage in login handler
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await verifyUser(username, password);
if (user) {
logSecurityEvent('login_success', {
ip: req.ip,
userId: user.id,
userAgent: req.headers['user-agent']
});
// ... handle successful login
} else {
logSecurityEvent('login_failure', {
ip: req.ip,
username: username, // OK to log username attempts
userAgent: req.headers['user-agent']
});
// Check for brute force
const attempts = await getFailedAttempts(req.ip);
if (attempts > 5) {
logSecurityEvent('brute_force_detected', {
ip: req.ip,
attempts: attempts
});
return res.status(429).json({
error: 'Too many failed attempts'
});
}
res.status(401).json({ error: 'Invalid credentials' });
}
});
15. CORS Configuration
❌ Bad: Wide Open CORS
// DANGEROUS - allows any origin
app.use(cors());
✅ Good: Restricted CORS:
const cors = require('cors');
const allowedOrigins = [
'https://yourapp.com',
'https://www.yourapp.com'
];
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:3000');
}
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
Quick Security Checklist
Before deploying to production, verify:
- [ ] All passwords are hashed with bcrypt/Argon2
- [ ] SQL queries use parameterized statements
- [ ] User input is validated and sanitized
- [ ] HTTPS is enforced (no HTTP)
- [ ] Security headers are set (CSP, HSTS, X-Frame-Options)
- [ ] CORS is properly configured
- [ ] Rate limiting is enabled
- [ ] Sessions use httpOnly, secure, sameSite cookies
- [ ] File uploads are restricted and validated
- [ ] No secrets in code (use environment variables)
- [ ] Dependencies are up to date (npm audit)
- [ ] Error messages don't leak sensitive info
- [ ] Logging doesn't include passwords/tokens
- [ ] Database users have minimal permissions
- [ ] Authentication endpoints have rate limiting
Final Thoughts
Security isn't about being paranoid—it's about being responsible. Every line of code you write is potentially an attack vector. By following these practices and using the code examples above, you'll dramatically reduce your application's attack surface.
Remember: Security is not a feature you add at the end. It's a mindset you adopt from day one. Start with secure defaults, validate everything, trust nothing, and always assume your code will be attacked.
Top comments (0)