Secure Coding Practices Every Developer Should Know
Key Takeaways
- •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).
- •SQL injection is responsible for more high-profile data breaches than any other single vulnerability class.
- •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.
- •Fast hashing algorithms (MD5, SHA-1, SHA-256, SHA-512) are designed for speed.
- •Hardcoded credentials are found constantly in public repositories.
- •Access control bugs are the #1 finding in web security assessments (OWASP A01 2021).
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 — it's 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 — 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, 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 — 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.