Secure Coding Practices Every Developer Should Know
Most vulnerabilities come from code. The secure coding principles that prevent injection, auth flaws and data exposure, with examples in multiple languages.
The 2017 Equifax breach that exposed 147 million people's personal data? An unpatched Apache Struts vulnerability, discovered and fixed by the vendor 66 days before the breach. The 2020 SolarWinds hack that compromised 18,000 organizations including the US Treasury and Pentagon? Build pipeline security failure. The 2019 Capital One breach? A misconfigured AWS IAM role and SSRF in a WAF.
In almost every major breach, the root cause traces back to a developer decision: a missing parameterized query, a hardcoded credential, a skipped authorization check, a verbose error message left on in production. Not exotic hardware exploits. Not sophisticated zero-days requiring nation-state resources. Ordinary coding mistakes.
This guide covers the secure coding practices that eliminate the most common and damaging vulnerability classes. Every principle maps to a real CVE or breach. Every code example shows the vulnerable pattern next to the fix, in Python, Node.js, Go, and PHP where relevant.
Principle 1: Never Trust Input From Outside Your Application Boundary
Everything that enters your application from outside is untrusted: HTTP requests, URL parameters, request headers, JSON bodies, file uploads, environment variables from third-party systems, inter-service API calls, and data from databases (which may have been previously poisoned via SQL injection or stored XSS).
Client-side validation is a UX feature, not a security control. An attacker bypasses it in 10 seconds with curl.
# VULNERABLE — raw user input directly to processing
@app.route('/transfer', methods=['POST'])
def transfer():
amount = request.json['amount']
recipient_id = request.json['recipient_id']
process_transfer(amount, recipient_id) # amount could be negative, recipient_id could be a UUID injection
return jsonify({'success': True})
# SAFE — validate type, range, format before processing
from pydantic import BaseModel, Field, validator
from uuid import UUID
from decimal import Decimal
class TransferRequest(BaseModel):
amount: Decimal = Field(gt=0, le=Decimal('10000.00')) # must be positive, max $10,000
recipient_id: UUID # must be a valid UUID format
note: str = Field(max_length=200, default='')
@validator('note')
def note_must_not_contain_html(cls, v):
import re
if re.search(r'<[^>]+>', v):
raise ValueError('HTML not permitted in transfer notes')
return v
@app.route('/transfer', methods=['POST'])
def transfer():
try:
data = TransferRequest(**request.json)
except ValidationError as e:
return jsonify({'error': 'Invalid input', 'details': e.errors()}), 400
process_transfer(data.amount, str(data.recipient_id))
return jsonify({'success': True})// Node.js — Zod schema validation
import { z } from 'zod';
const TransferSchema = z.object({
amount: z.number()
.positive('Amount must be positive')
.max(10000, 'Amount exceeds maximum transfer limit')
.multipleOf(0.01, 'Amount must have at most 2 decimal places'),
recipientId: z.string().uuid('Invalid recipient ID format'),
note: z.string().max(200).optional()
.refine(val => !val || !/<[^>]+>/.test(val), 'HTML not permitted in notes'),
});
app.post('/transfer', authenticate, async (req, res) => {
const result = TransferSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Validation failed', issues: result.error.issues });
}
const { amount, recipientId, note } = result.data;
await processTransfer(req.user.id, amount, recipientId, note);
return res.json({ success: true });
});File Upload Validation
File uploads are a consistently abused injection vector: web shell uploads, polyglot files, path traversal in filenames, and content-type bypass.
import magic
import os
import uuid
ALLOWED_MIME_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/pdf': '.pdf',
}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
file_data = file.read()
# Check file size
if len(file_data) > MAX_FILE_SIZE:
return jsonify({'error': 'File too large'}), 400
# Detect MIME type from file content (not from Content-Type header)
detected_mime = magic.from_buffer(file_data, mime=True)
if detected_mime not in ALLOWED_MIME_TYPES:
return jsonify({'error': f'File type not allowed: {detected_mime}'}), 400
# Generate safe filename — never use the user-supplied filename
extension = ALLOWED_MIME_TYPES[detected_mime]
safe_filename = f"{uuid.uuid4().hex}{extension}"
# Store outside the web root — not in static/ or public/
storage_path = os.path.join('/var/app/uploads', safe_filename)
with open(storage_path, 'wb') as f:
f.write(file_data)
return jsonify({'filename': safe_filename})Never trust the Content-Type header for file type validation, because it is trivially spoofed. Use a magic byte detector like python-magic or file-type (Node.js) that reads the actual file bytes. Also never store the user's original filename. Generate a random UUID-based name to prevent path traversal and directory traversal attacks.
Principle 2: Separate Code From Data With Parameterized Queries
SQL injection is responsible for more high-profile data breaches than any other single vulnerability class. The fix is universal and well-known. There is no legitimate reason to build SQL queries through string concatenation in any language, in any framework, in any year after approximately 1998.
# VULNERABLE — string formatting creates injectable SQL
def get_orders_for_user(user_id: str):
# Attacker sends user_id = "1 OR 1=1"
query = f"SELECT * FROM orders WHERE user_id = {user_id}"
return db.execute(query).fetchall()
# SAFE — parameterized query with exact type matching
def get_orders_for_user(user_id: int):
return db.execute(
"SELECT id, product, amount, status FROM orders WHERE user_id = %s",
(user_id,)
).fetchall()// Go — vulnerable and safe
package main
import "database/sql"
// VULNERABLE
func getUserVulnerable(db *sql.DB, username string) (*User, error) {
query := "SELECT id, email, role FROM users WHERE username = '" + username + "'"
row := db.QueryRow(query)
var u User
return &u, row.Scan(&u.ID, &u.Email, &u.Role)
}
// SAFE — prepared statement
func getUserSafe(db *sql.DB, username string) (*User, error) {
row := db.QueryRow(
"SELECT id, email, role FROM users WHERE username = $1",
username,
)
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Role); err != nil {
return nil, err
}
return &u, nil
}The ORM Raw Query Trap
ORMs are safe for standard CRUD operations, since they generate parameterized queries automatically. The trap is their raw query escape hatches, which developers use when they need complex queries and which are just as injectable as hand-written SQL if misused.
// Prisma — ORM safe operations
const orders = await prisma.order.findMany({
where: { userId: session.userId },
orderBy: { createdAt: 'desc' },
});
// VULNERABLE: Prisma's unsafe raw query method
const orders = await prisma.$queryRawUnsafe(
`SELECT * FROM orders WHERE userId = '${session.userId}'` // injectable
);
// SAFE: Prisma's tagged template raw query (parameterized automatically)
const orders = await prisma.$queryRaw`SELECT * FROM orders WHERE userId = ${session.userId}`;# SQLAlchemy — safe vs. vulnerable raw queries
from sqlalchemy import text
# VULNERABLE
db.session.execute(f"SELECT * FROM users WHERE username = '{username}'")
# SAFE — named bind parameters
db.session.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)Principle 3: Context-Aware Output Encoding
Cross-site scripting occurs when user-supplied data is inserted into an HTML page, a JavaScript string, a CSS value, or a URL without encoding appropriate to that context. The fix is context-specific: HTML encoding for HTML body/attributes, JavaScript encoding for JavaScript strings, URL encoding for URL parameters.
# Flask/Jinja2 — auto-escaping is on by default for .html templates
# NEVER use Markup() or | safe on user-supplied content
# VULNERABLE
@app.route('/welcome')
def welcome():
name = request.args.get('name', '')
# Markup() tells Jinja2 "this is trusted HTML, don't escape it"
return render_template('welcome.html', name=Markup(name))
# SAFE — pass raw string, let Jinja2 escape it
@app.route('/welcome')
def welcome():
name = request.args.get('name', '')
return render_template('welcome.html', name=name) # Jinja2 escapes {{ name }}// Vanilla JavaScript — never use innerHTML with user data
function displaySearchTerm(term) {
// VULNERABLE: innerHTML parses HTML and executes scripts
document.getElementById('search-term').innerHTML = 'You searched for: ' + term;
// SAFE: textContent treats value as literal text
document.getElementById('search-term').textContent = 'You searched for: ' + term;
}
// When HTML is genuinely needed — sanitize with DOMPurify
function renderUserBio(html) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'],
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
});
document.getElementById('bio').innerHTML = clean;
}JavaScript Context Encoding
HTML encoding (<) is not sufficient inside a JavaScript string context:
<!-- VULNERABLE: HTML encoding doesn't prevent JS string breakout -->
<script>
var username = "<?= htmlspecialchars($username) ?>";
// If $username = \"; alert(1);//
// htmlspecialchars doesn't encode \ or ;
// Result: var username = "\"; alert(1);//";
</script>
<!-- SAFE: Use JSON encoding for JavaScript contexts -->
<script>
var username = <?= json_encode($username) ?>;
// json_encode properly escapes all characters dangerous in JS strings
</script>// Node.js — safe server-side rendering for JavaScript contexts
app.get('/profile', (req, res) => {
const userData = {
name: user.name,
email: user.email,
};
// JSON.stringify handles all necessary escaping for JS context
// Replace < > & to prevent </script> injection
const userDataJson = JSON.stringify(userData)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
res.send(`<script>var currentUser = ${userDataJson};</script>`);
});Principle 4: Store Passwords Correctly, No Exceptions
Fast hashing algorithms (MD5, SHA-1, SHA-256, SHA-512) are designed for speed. A modern GPU can compute billions of SHA-256 hashes per second. The RockYou 2009 breach released 32 million plaintext passwords that became the definitive cracking dictionary. LinkedIn's SHA-1 hashes from the 2012 breach were 90% cracked within days of publication.
Password hashing must be deliberately slow and memory-intensive.
# VULNERABLE — fast hashes (MD5, SHA-1, SHA-256 variants)
import hashlib
# None of these are appropriate for passwords
hash1 = hashlib.md5(password.encode()).hexdigest() # broken
hash2 = hashlib.sha1(password.encode()).hexdigest() # broken
hash3 = hashlib.sha256(password.encode()).hexdigest() # not slow enough
hash4 = hashlib.sha256((password + salt).encode()).hexdigest() # still not slow enough
# SAFE — bcrypt (minimum work factor 12)
import bcrypt
def hash_password(password: str) -> str:
# bcrypt.gensalt() generates a random 128-bit salt automatically
# rounds=12 means 2^12 = 4096 iterations (takes ~0.3 seconds on modern hardware)
# rounds=14 means 2^14 = 16384 iterations (~1.2 seconds) — use for high-value accounts
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(password: str, stored_hash: str) -> bool:
return bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8'))# BEST — Argon2id (NIST SP 800-63B recommended, winner of Password Hashing Competition)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Configure with appropriate cost parameters
# memory_cost: 64MB RAM — makes GPU attacks expensive
# time_cost: 3 iterations
# parallelism: 4 threads
ph = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64 MB
parallelism=4,
hash_len=32,
salt_len=16
)
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, stored_hash: str) -> bool:
try:
ph.verify(stored_hash, password)
if ph.check_needs_rehash(stored_hash):
return True # caller should rehash and store new hash
return True
except VerifyMismatchError:
return False// Node.js — bcrypt
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}Timing Attack Prevention in Login
// VULNERABLE: returns immediately if user not found — timing difference reveals valid emails
async function login(email: string, password: string) {
const user = await db.users.findOne({ email });
if (!user) return { success: false }; // Fast — reveals user doesn't exist
const valid = await bcrypt.compare(password, user.passwordHash);
return { success: valid };
}
// SAFE: always run bcrypt.compare, even for non-existent users
const DUMMY_HASH = await bcrypt.hash('dummy-constant-value-for-timing-safety', 12);
async function login(email: string, password: string) {
const user = await db.users.findOne({ email });
const hashToVerify = user ? user.passwordHash : DUMMY_HASH;
// Always takes the same time whether user exists or not
const valid = await bcrypt.compare(password, hashToVerify);
if (!user || !valid) {
return { success: false }; // Same response for wrong email OR wrong password
}
return { success: true, userId: user.id };
}Principle 5: Never Hardcode Secrets
Hardcoded credentials are found constantly in public repositories. GitHub's secret scanning detected over 1 million exposed secrets in 2023. Any API key committed to a repository, even briefly and even to a private repo, must be considered compromised and rotated immediately.
// VULNERABLE — secrets in source code (stay in git history forever)
package main
const (
DBPassword = "SuperSecretPassword123!"
JWTSecret = "my-jwt-secret-key"
)
func connectDB() *sql.DB {
dsn := "postgres://admin:" + DBPassword + "@prod-db:5432/app"
db, _ := sql.Open("postgres", dsn)
return db
}
// SAFE — load from environment
import "os"
func connectDB() (*sql.DB, error) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return nil, fmt.Errorf("DATABASE_URL environment variable is required")
}
return sql.Open("postgres", dsn)
}Secrets Management at Scale
# AWS Secrets Manager — retrieve secret at runtime
import boto3
import json
def get_db_credentials() -> dict:
client = boto3.client('secretsmanager', region_name='us-east-1')
response = client.get_secret_value(SecretId='prod/app/database')
return json.loads(response['SecretString'])
creds = get_db_credentials()
connection_string = f"postgresql://{creds['username']}:{creds['password']}@{creds['host']}/{creds['dbname']}"# HashiCorp Vault — retrieve secret
vault kv get -field=password secret/prod/database
# In Python:
import hvac
client = hvac.Client(url='https://vault.internal:8200', token=os.getenv('VAULT_TOKEN'))
secret = client.secrets.kv.v2.read_secret_version(path='database/credentials')
db_password = secret['data']['data']['password']# Detect secrets already in git history
# TruffleHog — scans entire git history
trufflehog git https://github.com/yourorg/yourrepo --concurrency=20
# gitleaks
gitleaks detect --source . --verbose
# Add pre-commit hook to prevent future commits
pip install pre-commit
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.16.0
hooks:
- id: gitleaks
EOF
pre-commit installDeleting a secret from a git commit doesn't remove it from git history. If you accidentally commit a secret, assume it's compromised and rotate it immediately. Then rewrite the git history with git filter-repo. The rotation must happen before anything else, because someone may have already pulled the repo or a bot may have scraped it.
Principle 6: Authorization Must Be Checked Server-Side on Every Request
Access control bugs are the #1 finding in web security assessments (OWASP A01 2021). The most common pattern: the UI hides features from unauthorized users (correct), but the backend API endpoints those features call don't verify authorization (catastrophic).
Hiding admin menu items from non-admin users is not authorization. Checking req.user.role === 'admin' on every admin API endpoint is authorization.
// VULNERABLE — frontend hides the button, backend doesn't check role
// Frontend (React):
// {user.role === 'admin' && <button onClick={deleteUser}>Delete User</button>}
// Backend (Express) — missing role check:
app.delete('/api/admin/users/:id', authenticate, async (req, res) => {
// Only checks that a valid JWT exists — not that the user is an admin
await db.users.delete({ id: req.params.id });
return res.json({ success: true });
});
// Attacker sends: DELETE /api/admin/users/123 with any valid JWT
// User deleted — no admin role required// SAFE — middleware enforces role at the route level
import { Request, Response, NextFunction } from 'express';
function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
logger.warn('authz.violation', {
userId: req.user.id,
userRole: req.user.role,
requiredRoles: roles,
path: req.path,
method: req.method,
ip: req.ip,
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Route-level enforcement — no way to access without being admin
app.delete('/api/admin/users/:id',
authenticate,
requireRole(['admin', 'superadmin']),
async (req, res) => {
await db.users.delete({ id: req.params.id });
return res.json({ success: true });
}
);
// Resource-level enforcement for IDOR prevention
app.get('/api/invoices/:id',
authenticate,
async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
userId: req.user.id, // ALWAYS bind to authenticated user
});
if (!invoice) return res.status(403).json({ error: 'Not found' });
return res.json(invoice);
}
);Row-Level Security at the Database Layer (Postgres)
For critical applications, enforce authorization at the database level using Postgres Row Level Security (RLS). Even if the application code has a bug, the database will refuse to return unauthorized data.
-- Enable RLS on the invoices table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own invoices
CREATE POLICY user_invoices_policy ON invoices
FOR SELECT
USING (user_id = current_setting('app.current_user_id')::uuid);
-- Policy: users can only update their own unpaid invoices
CREATE POLICY user_invoices_update_policy ON invoices
FOR UPDATE
USING (user_id = current_setting('app.current_user_id')::uuid AND status = 'pending');
-- Admins can see all invoices
CREATE POLICY admin_invoices_policy ON invoices
FOR ALL
USING (current_setting('app.current_role') = 'admin');# Set the user context before any query
def execute_user_query(user_id: str, user_role: str, query_func):
with db.connection() as conn:
conn.execute("SET app.current_user_id = %s", (user_id,))
conn.execute("SET app.current_role = %s", (user_role,))
return query_func(conn)Principle 7: Error Handling Must Not Leak Internals
Verbose error messages are a direct information disclosure vulnerability. A stack trace containing database schema, internal file paths, server version, and environment variables is a gift to an attacker. The 2017 Equifax breach was partly enabled by verbose Spring Framework error pages that revealed internal details about the application architecture.
# VULNERABLE — raw exception reaches the client
@app.route('/search')
def search():
query = request.args.get('q')
results = db.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
# If the query throws: psycopg2.errors.SyntaxError: syntax error at or near "'"
# LINE 1: SELECT * FROM products WHERE name LIKE '%'%'
return jsonify(results.fetchall())
# Flask shows the full stack trace including DB type, file paths, query text# SAFE — structured error handling
import logging
import uuid
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(error):
error_id = str(uuid.uuid4())[:8] # Short ID for correlation
logger.exception(f"Unhandled error [{error_id}]:", exc_info=error)
# Log full details server-side, return only error ID to client
return jsonify({'error': 'An unexpected error occurred', 'errorId': error_id}), 500
@app.route('/search')
def search():
try:
query = request.args.get('q', '')
if not query or len(query) > 100:
return jsonify({'error': 'Invalid query'}), 400
results = db.execute(
"SELECT id, name, price FROM products WHERE name ILIKE %s LIMIT 20",
(f'%{query}%',)
).fetchall()
return jsonify([dict(r) for r in results])
except Exception as e:
error_id = str(uuid.uuid4())[:8]
logger.error(f"Search error [{error_id}]: {e}", exc_info=True)
return jsonify({'error': 'Search unavailable', 'errorId': error_id}), 500// Go — proper error handling with structured logging
package main
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/google/uuid"
)
type ErrorResponse struct {
Error string `json:"error"`
ErrorID string `json:"errorId"`
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" || len(query) > 100 {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid query"})
return
}
results, err := db.SearchProducts(query)
if err != nil {
errorID := uuid.New().String()[:8]
slog.Error("search failed",
"errorId", errorID,
"query", query,
"error", err,
)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{
Error: "Search unavailable",
ErrorID: errorID,
})
return
}
writeJSON(w, http.StatusOK, results)
}Principle 8: Security Headers Are Non-Negotiable
Security headers are one-line configurations that activate browser-enforced protections. Missing headers contribute to XSS exploitability, clickjacking, MIME sniffing, and information disclosure.
// Express.js — Helmet.js sets all security headers
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
noSniff: true,
frameguard: { action: 'deny' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
hidePoweredBy: true,
}));# Nginx — equivalent configuration
server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
}Principle 9: Dependency Security Is Application Security
Your npm/pip/go.mod is an attack surface. The xz-utils backdoor discovered in March 2024 showed how a supply chain attack can spend months building trust in an open-source project before injecting malicious code. The attacker gained merge access to a critical compression library by submitting legitimate contributions for two years before inserting the backdoor.
# Audit current dependencies
npm audit --audit-level=moderate
# Python
pip-audit --requirement requirements.txt
safety check
# Go
govulncheck ./...
# Verify package integrity — use lockfiles and never skip them
npm ci # Install from lockfile exactly — fails if package-lock.json doesn't match
# Verify npm package signatures
npm audit signatures# GitHub Actions — security scanning in CI
name: Security Scan
on: [push, pull_request]
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
semgrep:
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep --config p/owasp-top-ten --config p/secrets --errorPrinciple 10: HTTPS, CORS, and Cookie Security
Three configuration areas that developers frequently get wrong, each with direct security consequences.
HTTPS Enforcement
// Express — enforce HTTPS in production
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});CORS Configuration
// VULNERABLE — reflects any origin with credentials
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin); // any origin
res.header('Access-Control-Allow-Credentials', 'true'); // with credentials
next();
});
// Any website can make authenticated API calls to your server using the victim's session
// SAFE — explicit allowlist
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean));
app.use(cors({
origin: (origin, callback) => {
if (!origin || ALLOWED_ORIGINS.has(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origin ${origin} not allowed`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));Secure Cookie Configuration
// VULNERABLE — missing cookie security flags
res.cookie('session', token);
// No HttpOnly: JavaScript can read it (XSS → session theft)
// No Secure: sent over HTTP (interception risk)
// No SameSite: can be sent cross-site (CSRF attacks)
// SAFE — all security flags set
res.cookie('session', token, {
httpOnly: true, // Prevents JavaScript access
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Never sent in cross-site requests
maxAge: 3600000, // 1 hour expiry
path: '/',
domain: '.example.com',
});Static Analysis: Catching Issues Before Code Review
SAST tools find security issues in source code before it runs.
# Bandit — Python security linter
pip install bandit
bandit -r ./src -ll # report medium and high severity only
bandit -r ./src -f json -o bandit_report.json
# Common Bandit findings:
# B608: SQL query construction from user input
# B105: hardcoded password (heuristic)
# B301: pickle deserialization (RCE risk)
# B501: weak SSL/TLS version
# gosec — Go security scanner
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -severity medium ./...
# Semgrep — multi-language, OWASP rules
semgrep --config p/owasp-top-ten ./src
semgrep --config p/secrets ./src
# ESLint security plugin — JavaScript/TypeScript
npm install --save-dev eslint-plugin-security eslint-plugin-no-unsanitizedSecure Coding Checklist
Use this as a PR review checklist for any feature that handles user data, authentication, or sensitive operations:
| Category | Check | Vulnerability Prevented |
|---|---|---|
| Input | All user input validated with type + range + format | Injection, business logic abuse |
| Input | File uploads validated by MIME magic bytes, not Content-Type header | Web shell upload |
| SQL | All DB queries use parameterized statements | SQL injection |
| Output | Template output uses auto-escaping | XSS |
| Output | innerHTML only used with DOMPurify-sanitized content | DOM XSS |
| Auth | Passwords hashed with bcrypt/argon2 (not MD5/SHA) | Credential theft |
| Auth | Login endpoint rate-limited (5 attempts/15min) | Brute force |
| Auth | Session tokens are HttpOnly; Secure; SameSite=Strict | Session theft, CSRF |
| AuthZ | Every resource query binds to session.userId | IDOR / Broken Access Control |
| AuthZ | Every privileged endpoint checks user role | Privilege escalation |
| Secrets | No credentials in source code or config files | Credential exposure |
| Secrets | .env files are gitignored | Credential exposure |
| Errors | No stack traces or DB error details in API responses | Information disclosure |
| Headers | CSP, HSTS, X-Content-Type-Options, X-Frame-Options set | XSS, clickjacking |
| CORS | Only specific trusted origins allowed | Cross-origin credential theft |
| TLS | HTTP redirected to HTTPS; HSTS set | Data interception |
| Deps | npm audit / pip-audit run in CI with failure threshold | Known CVEs |
| SSRF | User-supplied URLs validated against blocklist for internal IPs | Cloud metadata theft |
None of these require extra libraries, significant time investment, or architectural changes. They require habits. Build them in during initial development rather than retrofitting them after a pentest report.
Where these vulnerability classes rank
The ten principles above are not a random collection. Each targets a class of flaw that appears near the top of real-world vulnerability data year after year. Seeing them ranked by prevalence and impact helps you decide where habits matter most.
| Vulnerability class | Root cause in code | Reference | Primary defense |
|---|---|---|---|
| Broken access control | Missing server-side authorization checks | OWASP A01, CWE-284 | Enforce authorization on every request and resource |
| Injection | Untrusted input mixed with code or queries | OWASP A03, CWE-89, CWE-79 | Parameterized queries, context-aware encoding |
| Cryptographic failures | Weak hashing, secrets in code, missing TLS | OWASP A02, CWE-327 | Argon2id or bcrypt, secrets manager, HTTPS |
| Insecure design | Security not considered during design | OWASP A04 | Threat modeling before implementation |
| Security misconfiguration | Default settings, verbose errors, missing headers | OWASP A05, CWE-16 | Hardened defaults, security headers, quiet errors |
| Vulnerable components | Outdated or malicious dependencies | OWASP A06, CWE-1104 | Dependency scanning, lockfiles, audit in CI |
| Identification and auth failures | Weak sessions, no rate limiting | OWASP A07, CWE-287 | Strong session flags, rate limits, MFA |
| Server-side request forgery | Unvalidated user-supplied URLs | OWASP A10, CWE-918 | Validate and allowlist outbound targets |
Access control sits at the top for a reason. It is the hardest class to catch with automated tooling, because whether a check is present is a question about intended behavior that a scanner cannot infer. A missing authorization check looks like perfectly valid code. This is why the discipline of binding every query to the authenticated user, shown in Principle 6, matters more than any single tool.
Injection has fallen from its historic top spot, not because it disappeared, but because frameworks made the safe path the default path. Parameterized queries and auto-escaping templates removed most opportunities to make the mistake. The remaining injection bugs cluster around the escape hatches: raw query methods, dynamic query building, and templates rendered with escaping disabled.
Threat modeling before you write code
Every principle above is a control applied to code that already exists. Insecure design, listed as OWASP A04, is the class those controls cannot fix, because the flaw is in the shape of the system rather than the correctness of a line. A password reset flow that lets an attacker enumerate valid accounts is not a coding bug in any single function. It is a design that leaked information by construction.
Threat modeling is the practice of asking, before implementation, what could go wrong. A lightweight version fits into any feature kickoff and needs no special tooling. Walk through four questions for the feature you are about to build.
First, what are you building, described as data flows rather than screens. Draw where data enters, where it is stored, which components it crosses, and where trust boundaries sit. A trust boundary is any point where data moves from a less trusted zone to a more trusted one, such as the edge between the browser and your API.
Second, what can go wrong at each boundary. A common structured approach considers spoofing an identity, tampering with data, repudiating an action, disclosing information, denying service, and elevating privilege. You do not need the acronym, you need the habit of asking each question at each boundary.
Third, what will you do about each risk you found. Some you mitigate with a control, some you accept because the impact is low, and some you push elsewhere in the system. Writing the decision down is what separates threat modeling from worrying.
Fourth, did you cover it. Revisit the model when the feature changes, because a design that was safe can become unsafe when a new data flow is added later.
Threat modeling does not require a formal methodology or a dedicated meeting for small changes. For a single feature, spend fifteen minutes drawing the data flow and asking what an attacker gains by tampering with each input or reading each output. The value is in asking the questions before the code is written, when changing the design is still cheap.
Building security into the development workflow
Habits do not survive on willpower. They survive because the workflow makes the secure path the default and catches the insecure path automatically. Three layers of automation carry most of the load, and each catches a different class of mistake at a different moment.
The first layer is the developer's own machine. Pre-commit hooks that run a secret scanner and a fast linter stop the most embarrassing mistakes, a hardcoded credential or an obviously unsafe function, before they ever reach the repository. Because they run in seconds, developers tolerate them, and because they run before the commit, they prevent the mistake rather than reporting it later.
The second layer is continuous integration. This is where the slower, deeper checks belong: static analysis across the whole codebase, dependency auditing against known vulnerability databases, and secret scanning across the full history. Setting a failure threshold, such as failing the build on any high-severity finding, turns these from advisory noise into a gate. The GitHub Actions and Semgrep configurations shown earlier are the machinery for this layer.
The third layer is code review by a human. Tools catch pattern-matchable flaws, and a reviewer catches the ones that require understanding intent: a missing authorization check, a business-logic flaw, a subtle trust boundary crossed without validation. The secure coding checklist earlier in this guide is meant to be used here, as a concrete list of questions a reviewer asks of any change that touches user data, authentication, or sensitive operations.
None of these layers is sufficient alone. Local hooks are easy to bypass, CI catches patterns but not intent, and human review is slow and inconsistent without a checklist. Together they form a net where a flaw that slips through one layer is likely caught by another. This is the same defense-in-depth idea that runs through the code-level principles, applied to the process that produces the code.
Common misconceptions
Several beliefs lead capable developers into insecure code, and naming them directly helps.
The first is that a framework makes an application secure by itself. Frameworks remove classes of mistake and provide safe defaults, and they also provide escape hatches, and the bugs migrate to those escape hatches. A raw query method, a template rendered with escaping off, or an authentication middleware applied to some routes and forgotten on others reintroduces the flaw the framework was preventing. The framework helps, and the developer still owns the outcome.
The second is that input validation and output encoding are the same defense. They are complementary and both required. Validation decides whether input is acceptable at the boundary, and encoding decides how to safely render data in a specific output context. Data can pass validation and still be dangerous when placed in an HTML attribute or a JavaScript string without the right encoding. Validate on the way in, encode on the way out.
The third is that hashing and encryption are interchangeable ways to protect passwords. They are not. Encryption is reversible by design, which means a stolen key exposes every password. Password storage needs a slow one-way hash such as Argon2id or bcrypt, from which the original cannot be recovered even by the system that stored it. Encrypting passwords is a serious mistake that looks like a security measure.
The fourth is that internal services do not need the same rigor as public ones. The flat, trusted internal network is exactly what an attacker exploits after gaining a foothold. An internal API without authorization checks is an escalation path waiting to be used. Apply the same input handling, authorization, and secret management to internal services as to public ones.
The fifth is that security work can be deferred to a hardening phase before release. Retrofitting security onto a design that did not account for it is expensive and incomplete. The cheap moment to prevent a class of flaw is while the code that would contain it is being written, which is why these are framed as habits rather than a checklist run once at the end.
Frequently asked questions
Is client-side validation ever useful for security?
Client-side validation improves the user experience by giving instant feedback, and it has no security value on its own because an attacker bypasses it trivially with a direct request. Every rule enforced in the browser must also be enforced on the server, where the attacker cannot reach. Treat client-side checks as a convenience and server-side checks as the control.
How often should dependencies be updated?
Run an automated audit on every build and act on high-severity findings promptly rather than on a fixed calendar. Use lockfiles so builds are reproducible, and prefer a tool that opens update pull requests automatically so patches do not pile up. The xz-utils incident showed that a compromised dependency can be introduced deliberately, so pinning versions and reviewing changes matters as much as staying current.
Do I need Argon2id, or is bcrypt good enough?
Bcrypt with a work factor of at least 12 remains a sound choice and is widely supported. Argon2id is the current recommendation because its memory cost makes large-scale GPU and custom-hardware attacks more expensive, and it won the Password Hashing Competition for that reason. Either is acceptable when configured properly. What is not acceptable is a fast general-purpose hash such as MD5, SHA-1, or plain SHA-256 for passwords.
Where should secrets live if not in code?
In environment variables for simple deployments, and in a dedicated secrets manager such as AWS Secrets Manager or HashiCorp Vault for anything at scale. A secrets manager adds access control, auditing, and rotation that environment variables lack. Whatever the mechanism, a secret that ever reached source control must be rotated, because git history preserves it even after the line is deleted.
What is the difference between authentication and authorization?
Authentication establishes who the caller is, and authorization decides what that caller is allowed to do. mTLS or a login proves identity, and a role check or an ownership check enforces permission. Broken access control, the most common serious web flaw, is an authorization failure: the caller is correctly identified and is then allowed to do something they should not.
Can static analysis replace manual code review?
No. Static analysis reliably catches pattern-matchable flaws such as string-built SQL, hardcoded credentials, and unsafe deserialization, and it runs on every change without fatigue. It cannot reason about intent, so it misses missing authorization checks and business-logic flaws that look like valid code. Use static analysis to handle the mechanical findings so human reviewers can spend their attention on the judgment calls tooling cannot make.
What is the single highest-impact habit to adopt first?
Bind every data-access query to the authenticated user and check authorization on every request server-side. Broken access control is the most prevalent serious flaw, it is the hardest for tools to catch, and the fix is a discipline rather than a library. If you adopt one habit, make it the reflex of asking, on every endpoint, whether this specific user is allowed to touch this specific resource.
How should errors be handled so they help debugging without leaking internals?
Log the full detail server-side, including the stack trace, the query, and a correlation identifier, and return only a generic message and that same identifier to the client. Support can then find the exact server-side log for a reported error without the response ever exposing database schema, file paths, or version numbers to an attacker. A verbose error page is a reconnaissance gift, and a silent one frustrates your own team, so the correlation identifier is what satisfies both needs at once.
Does using HTTPS mean my application is secure?
HTTPS protects data in transit and is mandatory, and it does nothing about the flaws inside the application. An application served over HTTPS can still have injection, broken access control, weak password storage, and every other class listed here. Transport encryption is one layer among many, and it should be paired with HSTS to prevent downgrade and with secure cookie flags so the session it protects cannot be stolen through other paths.