Secure Coding — Building Software That Does Not Break Under Attack
Input validation, output encoding, parameterised queries, secrets management, dependency hygiene, SAST/DAST in CI/CD, and threat modelling — the complete developer security toolkit.
IBM System Sciences Institute measured the cost of fixing a defect at each phase of the software development lifecycle. A bug fixed during design costs 1×. The same bug fixed during testing costs 10×. Fixed in production after a breach? 100× or more — and that ignores regulatory fines, litigation, and brand damage.
Security is not a layer you bolt on after the feature is built. It is a set of habits that cost almost nothing when practiced from the start and enormous amounts when ignored. This module teaches those habits: the precise techniques that prevent the vulnerability classes attackers exploit most — SQL injection, XSS, broken authentication, secrets leakage, vulnerable dependencies — and the tooling that catches what human review misses.
The Secure Design Principles Every Developer Must Know
Before any code is written, certain design principles should shape every decision. These are not theoretical ideals — they are the direct ancestors of real controls in real systems.
| Principle | What it means | Violation example |
|---|---|---|
| Least privilege | Every component runs with the minimum permissions needed — nothing more | App DB user has CREATE TABLE and DROP rights it never needs |
| Defense in depth | Multiple independent controls so one failure does not cause a breach | Relying on WAF alone; no input validation in application code |
| Fail securely | When errors occur, the system defaults to denying access, not granting it | catch(e) {} swallows auth error and lets request through |
| Separation of concerns | Authentication, authorisation, business logic, and data access in separate layers | Controller directly queries DB and makes auth decisions inline |
| Minimise attack surface | Disable unused features, endpoints, ports, and services | Admin API exposed on public internet "for convenience" |
| Never trust user input | All data from outside the trust boundary is hostile until proven otherwise | Using raw query params in SQL strings |
| Avoid security by obscurity | Security must not depend on the attacker not knowing implementation details | "Our API paths are random GUIDs so attackers won't find them" |
| Secure defaults | Out-of-the-box configuration is the most restrictive that still works | Framework ships with debug mode on; developers forget to disable it |
Input Validation — The First Line of Defence
Every piece of data that enters your application from the outside world — HTTP parameters, JSON bodies, headers, cookies, file uploads, environment variables from external systems, data read from a database you did not write — is untrusted. Input validation is the process of enforcing that data conforms to what you expect before it touches any sensitive operation.
There are two validation philosophies. Allowlist validation (also called whitelist) defines exactly what is permitted and rejects everything else. Blocklist validation defines what is forbidden and permits everything else. Allowlist is always stronger — attackers excel at finding characters and encodings that bypass blocklists.
# WEAK — blocklist approach (easy to bypass)
def validate_username(username):
forbidden = ["'", '"', ";", "--", "/*"]
for char in forbidden:
if char in username:
raise ValueError("Invalid character")
return username
# Bypass: username = "admin\x27" (URL-encoded single quote)
# Or: username = "admin\u0027" (Unicode escape)
# STRONG — allowlist approach
import re
def validate_username(username: str) -> str:
if not isinstance(username, str):
raise TypeError("Username must be a string")
if not 3 <= len(username) <= 32:
raise ValueError("Username must be 3-32 characters")
if not re.fullmatch(r'[a-zA-Z0-9_-]+', username):
raise ValueError("Username may only contain letters, digits, _ and -")
return username
# For structured data — use a validation library (Pydantic, Zod, Joi)
from pydantic import BaseModel, EmailStr, constr, validator
from typing import Optional
class CreateUserRequest(BaseModel):
username: constr(min_length=3, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$')
email: EmailStr
age: Optional[int] = None
@validator('age')
def age_range(cls, v):
if v is not None and not 13 <= v <= 120:
raise ValueError('Age must be between 13 and 120')
return v
# Pydantic raises ValidationError on first invalid field
# with a structured error you can return as 400 Bad RequestValidation must happen server-side, always. Client-side validation (JavaScript form checks) is a UX convenience — attackers bypass it by sending raw HTTP requests with curl or Burp Suite. Never rely on it as a security control.
Special cases that catch developers off guard:
| Input type | Attack if not validated | Correct validation |
|---|---|---|
| File upload | Attacker uploads webshell.php disguised as image.jpg | Check magic bytes (not extension), restrict MIME types, scan with AV, store outside web root |
| Redirect URL | Open redirect: attacker links to your site then redirects to phishing page | Allowlist redirect targets or use opaque tokens mapped server-side |
| Integer input | Integer overflow, negative quantities, absurdly large values | Enforce min/max bounds explicitly; never trust numeric range from client |
| JSON arrays | Mass assignment — extra fields overwrite internal fields like role or isAdmin | Explicitly declare accepted fields; use a strict schema; never spread raw body onto model |
| Unicode | Homograph attacks, case-folding bypasses, null byte injection | Normalise to NFC/NFKC before validation; reject null bytes; validate after normalisation |
Output Encoding — Preventing Injection at the Output Layer
Input validation controls what enters the system. Output encoding controls what leaves it safely. Injection attacks — XSS, SQL injection, command injection, LDAP injection — all share the same root cause: data is interpreted as code because it was not encoded correctly for its destination context.
The key insight is that encoding is context-dependent. The same string requires different encoding depending on where it is placed:
User input: O'Brien <script>alert(1)</script> HTML body context: O'Brien <script>alert(1)</script> → < and > prevent tag injection HTML attribute context (double-quoted): O'Brien <script>alert(1)</script> → ' prevents attribute break-out JavaScript string context: O\u0027Brien \u003cscript\u003ealert(1)\u003c\/script\u003e → Unicode escape prevents script injection URL query parameter context: O%27Brien%20%3Cscript%3Ealert%281%29%3C%2Fscript%3E → Percent-encoding for URL safety CSS property context: Never interpolate untrusted data into CSS — no encoding is fully safe here
Modern frameworks handle HTML encoding automatically — React JSX, Django templates, Jinja2, and Angular all HTML-encode by default. The dangerous moments are when you opt out:
// DANGEROUS — React dangerouslySetInnerHTML with unencoded user data
function Comment({ text }) {
return <div dangerouslySetInnerHTML={{ __html: text }} /> // XSS if text is unsanitised
}
// SAFE — React encodes automatically
function Comment({ text }) {
return <div>{text}</div> // React encodes < > & " ' automatically
}
// When you genuinely need to render HTML (e.g., CMS content):
import DOMPurify from 'dompurify'
function RichComment({ html }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href'],
})
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
// Python — Jinja2 auto-escapes in .html templates
# SAFE: {{ user.name }} → auto HTML-encoded
# UNSAFE: {{ user.name | safe }} → bypasses encoding — never use with untrusted data
# For non-HTML contexts, encode explicitly:
import html, urllib.parse, shlex
html.escape(user_input) # HTML context
urllib.parse.quote(user_input) # URL context
shlex.quote(user_input) # Shell argument (still prefer subprocess list form)Parameterised Queries — The Definitive SQL Injection Fix
SQL injection has appeared in the OWASP Top 10 every year since 2003. It is entirely preventable with one technique: parameterised queries (also called prepared statements). The database driver sends the SQL template and the data separately — the database engine never confuses data with SQL syntax.
# VULNERABLE — string concatenation
def get_user(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
return db.execute(query)
# Attack: username = "' OR '1'='1" → dumps all users
# Attack: username = "'; DROP TABLE users; --" → destroys table
# SAFE — parameterised query (Python psycopg2)
def get_user(username: str):
query = "SELECT id, email, role FROM users WHERE username = %s"
return db.execute(query, (username,))
# Driver sends query and data separately — no injection possible
# SAFE — SQLAlchemy ORM (parameterised automatically)
from sqlalchemy import select
stmt = select(User).where(User.username == username)
result = session.execute(stmt)
# SAFE — SQLAlchemy Core with text() for raw SQL
from sqlalchemy import text
stmt = text("SELECT id, email FROM users WHERE username = :username")
result = session.execute(stmt, {"username": username})
# Node.js — pg library
const result = await pool.query(
'SELECT id, email FROM users WHERE username = $1',
[username]
)
# Node.js — mysql2
const [rows] = await connection.execute(
'SELECT id, email FROM users WHERE username = ?',
[username]
)
# Java — PreparedStatement
PreparedStatement ps = conn.prepareStatement(
"SELECT id, email FROM users WHERE username = ?"
);
ps.setString(1, username);
ResultSet rs = ps.executeQuery();
# SECOND-ORDER SQL INJECTION — data stored then used in a query later
# Even parameterised inserts don't protect against this pattern:
# Step 1: attacker stores malicious username (safely inserted)
username = "admin'--"
db.execute("INSERT INTO users (username) VALUES (%s)", (username,))
# Step 2: developer later uses stored value without parameterisation
stored = db.fetchone("SELECT username FROM users WHERE id = %s", (user_id,))
# BUG: uses stored value in a new unparameterised query
db.execute(f"SELECT * FROM admins WHERE username = '{stored['username']}'")
# Fix: ALWAYS parameterise, even with "trusted" data from your own DBDynamic ORDER BY and table names cannot be parameterised — the database driver only allows data placeholders for values, not identifiers. For these cases, use an explicit allowlist:
ALLOWED_SORT_COLUMNS = {'created_at', 'username', 'email', 'score'}
ALLOWED_SORT_DIRS = {'ASC', 'DESC'}
def get_users_sorted(sort_col: str, sort_dir: str):
if sort_col not in ALLOWED_SORT_COLUMNS:
sort_col = 'created_at' # safe default
if sort_dir.upper() not in ALLOWED_SORT_DIRS:
sort_dir = 'ASC'
# Now safe to interpolate — values are controlled by allowlist
query = f"SELECT * FROM users ORDER BY {sort_col} {sort_dir}"
return db.execute(query)Secrets Management — Keeping Credentials Out of Code
The GitHub Secret Scanning team reports that over 1 million secrets are accidentally committed to public repositories every year. API keys, database passwords, private keys, and tokens embedded in source code are discovered within minutes by bots that scrape every public commit. Even private repositories are at risk — secrets in git history persist after deletion, accessible to every employee and contractor with read access.
The core rule: secrets never belong in source code. Not in comments. Not in test files. Not in config files checked into git. Not in Docker build args. Not in CI/CD logs.
# BAD — hardcoded credentials (found in production codebases constantly)
DATABASE_URL = "postgresql://admin:SuperSecret123@prod-db.example.com/app"
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
STRIPE_KEY = "sk_live_EXAMPLE_REPLACE_WITH_REAL_KEY"
# BETTER — environment variables (still risky if env is logged or leaked)
import os
DATABASE_URL = os.environ["DATABASE_URL"]
# Problem: .env files committed, docker-compose.yml with real values, CI logs
# BEST — dedicated secrets manager
# AWS Secrets Manager
import boto3, json
def get_secret(secret_name: str) -> dict:
client = boto3.client('secretsmanager', region_name='us-east-1')
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response['SecretString'])
db_creds = get_secret("prod/myapp/database")
conn = psycopg2.connect(
host=db_creds["host"],
user=db_creds["username"],
password=db_creds["password"],
dbname=db_creds["dbname"]
)
# HashiCorp Vault
import hvac
client = hvac.Client(url='https://vault.internal:8200', token=os.environ['VAULT_TOKEN'])
secret = client.secrets.kv.v2.read_secret_version(path='myapp/database')
password = secret['data']['data']['password']Secrets manager patterns to follow in every project:
| Practice | Why it matters |
|---|---|
| Short TTL tokens | AWS IAM roles with temporary credentials expire in minutes/hours — stolen creds have a small window |
| Secret rotation | Rotate DB passwords, API keys on a schedule (90 days max). Secrets manager can automate Lambda-triggered rotation |
| Audit access logs | Every read of a secret is logged — detect unusual access patterns before they become breaches |
| Least-privilege IAM | Each service reads only the secrets it needs; a compromised microservice cannot read another service's secrets |
| Separate per-environment | Prod secrets isolated from staging; dev secrets have no access to production data |
| Pre-commit hooks | Run gitleaks or git-secrets before every commit to catch accidental hardcoding locally |
| Scan CI history | truffleHog scans git history for high-entropy strings and known secret patterns |
# Pre-commit hook — add to .git/hooks/pre-commit (or use pre-commit framework)
#!/bin/sh
# Run gitleaks on staged files
gitleaks protect --staged --redact
if [ $? -ne 0 ]; then
echo "ERROR: Potential secret detected. Commit blocked."
echo "If false positive, add to .gitleaksignore"
exit 1
fi
# .pre-commit-config.yaml (pre-commit framework)
repos:
- repo: https://github.com/zricethezav/gitleaks
rev: v8.18.0
hooks:
- id: gitleaksDependency Management and Supply Chain Security
The average Node.js application has over 1,000 transitive dependencies. Each one is code you did not write, maintained by people you did not vet, introducing CVEs you did not choose to accept. Supply chain attacks — where attackers compromise a popular package to inject malicious code into thousands of downstream applications — have grown dramatically: the SolarWinds breach, the event-stream npm package backdoor, and the XZ Utils compromise all exploited this vector.
Software Composition Analysis (SCA) tools scan your dependency tree against vulnerability databases (NVD, GitHub Advisory, OSV) and flag packages with known CVEs.
# Python — audit with pip-audit and safety
pip install pip-audit
pip-audit # scan installed packages
pip-audit -r requirements.txt # scan requirements file
pip-audit --fix # auto-upgrade vulnerable packages
pip install safety
safety check # check against PyPI Safety DB
# Node.js — built-in and third-party
npm audit # scan for vulnerabilities
npm audit fix # auto-fix where possible
npm audit fix --force # fix even with breaking changes (review first)
npx snyk test # Snyk: deeper analysis + licence checks
npx audit-ci --critical # fail CI on critical vulns only
# Java — OWASP Dependency-Check
mvn org.owasp:dependency-check-maven:check
# or Gradle:
./gradlew dependencyCheckAnalyze
# Container images — Trivy
trivy image python:3.11 # scan base image
trivy fs . # scan filesystem / project
trivy image --severity HIGH,CRITICAL myapp:latest
# GitHub Actions — automated dependency scanning
# Dependabot (dependabot.yml):
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily" # daily for npm — ecosystem moves fasterLock files (package-lock.json, Pipfile.lock, poetry.lock) pin the exact versions of every dependency including transitive ones. Commit them. Without a lock file, npm install can pull a different (potentially malicious) version on each build.
Beyond CVEs, watch for these supply chain attack patterns:
| Attack type | How it works | Defence |
|---|---|---|
| Typosquatting | Register "reqeusts" or "colourama" — developers typo the install | Verify package name, check download count and creation date before installing new packages |
| Dependency confusion | Publish a public package with same name as an internal package — package manager pulls the public one | Scope internal packages (@company/pkg), set up private registry with upstream proxy |
| Maintainer compromise | Attacker compromises a maintainer's npm/PyPI account and publishes malicious version | Pin to exact versions + hashes in lock file, review diff before upgrading |
| Build system injection | Malicious code in build scripts, postinstall hooks, setup.py | Run npm install in a sandbox, review postinstall scripts, use --ignore-scripts for known-good packages |
| SBOM manipulation | Attacker modifies software bill of materials to hide malicious component | Generate SBOM from source at build time with Syft/CycloneDX, sign it with Cosign |
Security Testing in CI/CD — Shifting Left
Shifting security left means catching vulnerabilities in the development pipeline before code reaches production. Three automated test types integrate directly into CI/CD:
SAST (Static Application Security Testing) analyses source code without executing it. It finds SQL injection, hardcoded secrets, path traversal, insecure crypto, and hundreds of other patterns by matching against rule sets. Tools: Semgrep, Bandit (Python), ESLint with security plugins (JS), CodeQL (GitHub), SonarQube.
DAST (Dynamic Application Security Testing) sends attack payloads to a running application. It finds XSS, injection flaws, broken auth, SSRF, and misconfigurations that only appear at runtime. Tools: OWASP ZAP, Nuclei, Burp Suite Enterprise.
SCA (Software Composition Analysis) scans dependencies for known CVEs — covered above with pip-audit, npm audit, Trivy, Snyk.
# GitHub Actions — complete security pipeline
name: Security Pipeline
on: [push, pull_request]
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Semgrep SAST — 3,000+ rules for Python, JS, Go, Java, etc.
- name: Semgrep SAST
uses: semgrep/semgrep-action@v1
with:
config: >-
p/security-audit
p/secrets
p/owasp-top-ten
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_TOKEN }}
# Bandit — Python-specific SAST
- name: Bandit (Python)
run: |
pip install bandit
bandit -r src/ -ll -f json -o bandit-report.json
# -ll = only medium and high severity
# Gitleaks — secret scanning
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sca:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
exit-code: '1' # fail pipeline on HIGH/CRITICAL
dast:
runs-on: ubuntu-latest
needs: [build] # run after app is deployed to staging
steps:
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.11.0
with:
target: 'https://staging.example.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a' # ajax spider for SPAsPractical integration thresholds to avoid alert fatigue:
| Finding severity | Action | Timeline |
|---|---|---|
| Critical (CVSS 9.0+) | Block merge, page on-call, treat as P1 incident | Fix within 24 hours |
| High (CVSS 7.0–8.9) | Block merge to main, create tracked ticket | Fix within 7 days |
| Medium (CVSS 4.0–6.9) | Create ticket, allow merge with acknowledgement | Fix within 30 days |
| Low / Informational | Log for review, do not block pipeline | Address in quarterly security sprint |
| Secret detected | Block merge immediately, rotate the secret | Rotate before merge is possible |
Authentication and Session Security in Code
Authentication flaws are so common because the implementation details matter enormously. Getting 95% right still means accounts are compromised.
# Password storage — NEVER store plaintext or MD5/SHA1
# BAD — MD5 (broken in seconds with rainbow tables)
import hashlib
stored_hash = hashlib.md5(password.encode()).hexdigest()
# BAD — SHA256 without salt (vulnerable to rainbow tables)
stored_hash = hashlib.sha256(password.encode()).hexdigest()
# GOOD — bcrypt (adaptive, built-in salt, work factor)
import bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# Verify:
bcrypt.checkpw(password.encode(), hashed)
# BETTER — Argon2id (winner of Password Hashing Competition, recommended by OWASP)
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # 64 MB RAM
parallelism=4,
hash_len=32,
salt_len=16,
)
hashed = ph.hash(password)
# Verify:
try:
ph.verify(hashed, password)
if ph.check_needs_rehash(hashed):
hashed = ph.hash(password) # upgrade parameters on login
except Exception:
raise InvalidPasswordError()
# Secure session token generation
import secrets
session_token = secrets.token_urlsafe(32) # 256 bits of randomness
# NEVER use: random.random(), uuid4() for security tokens, or predictable sequences
# Constant-time comparison — prevents timing attacks on token comparison
import hmac
def verify_token(provided: str, stored: str) -> bool:
return hmac.compare_digest(
provided.encode('utf-8'),
stored.encode('utf-8')
)
# DO NOT use: provided == stored (short-circuits, leaks length via timing)
# Session fixation prevention — regenerate session ID on privilege change
# Flask example:
from flask import session
session.clear() # clear old session
session.regenerate() # new session ID
session['user_id'] = user.id # set new session data after login
# Cookie security flags
Set-Cookie: session=token; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
# HttpOnly — JavaScript cannot read the cookie (XSS mitigation)
# Secure — only sent over HTTPS
# SameSite=Strict — not sent in cross-site requests (CSRF mitigation)
# Max-Age — explicit expiryCryptography in Code — Using It Without Breaking It
The golden rule of applied cryptography: do not implement cryptographic primitives yourself. Use high-level libraries. Even expert cryptographers make implementation mistakes. Timing side channels, padding oracle vulnerabilities, nonce reuse — these are subtle and catastrophic.
# Symmetric encryption — AES-GCM (authenticated encryption)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
key = os.urandom(32) # 256-bit key — store in secrets manager
aesgcm = AESGCM(key)
# Encrypt
nonce = os.urandom(12) # 96-bit nonce — NEVER reuse with same key
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
# Decrypt — raises InvalidTag if tampered
plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data)
# BAD — AES-ECB (identical plaintext blocks produce identical ciphertext)
# BAD — AES-CBC without MAC (padding oracle attacks)
# BAD — reusing nonces with GCM (catastrophic — breaks authentication)
# Asymmetric encryption — use X25519 for key exchange, Ed25519 for signing
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
signature = private_key.sign(message)
public_key.verify(signature, message) # raises InvalidSignature on failure
# BAD — RSA with PKCS#1 v1.5 padding (ROBOT attack, Bleichenbacher)
# GOOD — RSA with OAEP padding for encryption, PSS for signing
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
ciphertext = public_key.encrypt(message, padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
))
# Hashing — SHA-256 for integrity, not passwords
import hashlib
digest = hashlib.sha256(data).hexdigest()
# HMAC — for message authentication codes
import hmac, hashlib
mac = hmac.new(key, message, hashlib.sha256).digest()
# Verify:
expected = hmac.new(key, received_message, hashlib.sha256).digest()
hmac.compare_digest(mac, expected) # constant-time
# TLS — let the OS/framework handle it
# Python requests — always verify certs (default True, never set verify=False)
import requests
response = requests.get('https://api.example.com', verify=True)
# Never: verify=False — defeats TLS entirely
# Pinning (for high-security mobile/desktop clients)
from requests_toolbelt.adapters import host_header_ssl
# Or use OS certificate pinning APIs| Use case | Use this | Never use |
|---|---|---|
| Password hashing | Argon2id, bcrypt (cost ≥ 12), scrypt | MD5, SHA-1, SHA-256 (unsalted), custom |
| Symmetric encryption | AES-256-GCM, ChaCha20-Poly1305 | DES, 3DES, AES-ECB, AES-CBC without MAC |
| Key exchange | X25519 (ECDH), FFDHE-4096+ | RSA < 2048, DH < 2048, ECDH P-192 |
| Digital signatures | Ed25519, ECDSA P-256, RSA-PSS | RSA-PKCS#1v1.5, DSA, ECDSA without deterministic k |
| Hashing / integrity | SHA-256, SHA-3, BLAKE2 | MD5, SHA-1, CRC32 |
| Random numbers | secrets module, os.urandom(), CSPRNG | random module, Math.random(), time-seeded PRNGs |
| TLS version | TLS 1.3 (TLS 1.2 minimum) | SSLv3, TLS 1.0, TLS 1.1 |
Threat Modelling — Finding Vulnerabilities Before Attackers Do
Threat modelling is a structured process for identifying what could go wrong in a system before it is built or changed. Done at design time, it costs one meeting and a whiteboard. Done after a breach, it costs millions and reputations.
The most widely used framework is STRIDE, developed by Microsoft. Each letter is a threat category:
| Threat | Violates | Example | Control |
|---|---|---|---|
| Spoofing | Authentication | Attacker forges another user's JWT | Strong auth, signature verification |
| Tampering | Integrity | Attacker modifies order total in transit | HMAC, TLS, signed tokens |
| Repudiation | Non-repudiation | User denies placing an order they made | Immutable audit logs, digital signatures |
| Information Disclosure | Confidentiality | Error message reveals DB schema | Generic errors, TLS, least-privilege DB access |
| Denial of Service | Availability | Attacker floods endpoint, crashes service | Rate limiting, circuit breakers, autoscaling |
| Elevation of Privilege | Authorisation | Regular user accesses admin endpoint | RBAC, ABAC, explicit authorisation checks on every action |
A practical threat modelling session for a new feature takes 60–90 minutes with 3–5 people (developer, security engineer, product manager, architect). The output is a list of threats with mitigations, not a document — a Jira ticket per unmitigated threat is sufficient.
Threat modelling session agenda (90 minutes): 1. Scope (10 min) - What are we modelling? New payment flow? Password reset? File upload? - Draw a simple data flow diagram: actors → components → data stores - Mark trust boundaries: where does data cross from untrusted to trusted? 2. Asset identification (10 min) - What is valuable? User PII, payment data, session tokens, private keys - What must stay available? Core user journeys 3. Threat enumeration using STRIDE (40 min) - Walk each data flow on the diagram - For each flow: "Can an attacker Spoof/Tamper/etc. at this boundary?" - Don't filter yet — write everything down 4. Risk rating (15 min) - DREAD or CVSS-lite: severity × likelihood - Identify top 5 risks 5. Mitigations (15 min) - For each top risk: what control prevents or detects it? - Accept, transfer, mitigate, or avoid - Create tickets for unmitigated risks before feature is merged
Tools that automate parts of threat modelling: OWASP Threat Dragon (free, diagram-driven), Microsoft Threat Modeling Tool (STRIDE-native), IriusRisk (enterprise, integrates with Jira).
Error Handling and Logging — Information Leakage and Audit Trails
Error messages are a dual-edged problem. Too much detail and you give attackers a roadmap — stack traces reveal framework versions, file paths, database schemas, and internal logic. Too little detail and developers cannot debug production issues.
# BAD — leaks stack trace, DB schema, file paths to client
@app.errorhandler(Exception)
def handle_error(e):
return jsonify({
"error": str(e),
"traceback": traceback.format_exc(), # NEVER
"query": current_query, # NEVER
}), 500
# GOOD — generic client response, detailed internal logging
import uuid, logging
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(e):
error_id = str(uuid.uuid4())
logger.error(
"Unhandled exception",
extra={
"error_id": error_id,
"error": str(e),
"traceback": traceback.format_exc(),
"user_id": g.get("user_id"),
"path": request.path,
"method": request.method,
}
)
return jsonify({
"error": "An unexpected error occurred",
"error_id": error_id, # safe: user can report this for support
}), 500
# Security audit logging — what to log
import json
from datetime import datetime, timezone
def audit_log(event: str, user_id: str | None, details: dict, outcome: str):
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event,
"user_id": user_id,
"outcome": outcome, # "success" | "failure"
"details": details,
"ip": request.remote_addr,
"user_agent": request.headers.get("User-Agent"),
}
security_logger.info(json.dumps(entry))
# Events to always log:
# auth.login_success, auth.login_failure, auth.logout
# auth.mfa_success, auth.mfa_failure
# account.password_change, account.email_change
# authz.access_denied (user tried to access something they shouldn't)
# admin.privilege_granted, admin.user_deleted
# data.export, data.bulk_delete (high-value data operations)
# What NOT to log:
# Passwords (even hashed), session tokens, credit card numbers,
# full SSNs, health data — log the action, not the sensitive valueLog aggregation matters as much as log generation. Logs written to a server that an attacker can then delete are useless for forensics. Ship logs to a centralised SIEM (Splunk, Elastic, Datadog) in real time, with write-once storage.
Workplace Scenario — The Security Review That Saved a Fintech Launch
A fintech startup is three weeks from launching a lending API. A contracted security engineer runs a SAST scan and threat model on the codebase. The findings:
| Finding | Severity | Impact | Fix time |
|---|---|---|---|
| AWS RDS password hardcoded in config.py committed to GitHub | Critical | Full database access — all 12,000 loan applications | 2 hours (rotate secret, move to Secrets Manager) |
| Loan amount parameter not validated — accepts negative values | High | Attacker requests -$50,000 loan, credits their account | 30 minutes (add min=0 validator) |
| SQL query built with f-string for loan status filter | High | SQL injection — dump all loan records | 1 hour (parameterise query) |
| Error responses include SQLAlchemy exception with table names | Medium | Reveals schema, helps attacker craft targeted SQLi | 15 minutes (generic error handler) |
| Session tokens generated with Python random module | High | Predictable tokens — session hijacking | 20 minutes (replace with secrets.token_urlsafe) |
| No rate limiting on loan application endpoint | Medium | Automated enumeration of SSNs via timing differences | 2 hours (add rate limiter, normalise response times) |
Total remediation time: one sprint (two weeks). The hardcoded AWS credential had already been scraped by a GitHub scanner bot — the team rotated it in time. If launched with these vulnerabilities, a single breach would have triggered PCI-DSS and GLBA notification requirements, potential state AG investigation, and class-action exposure for every affected borrower.
The security review cost $8,000. The prevented breach would have cost an estimated $2–4 million in fines, remediation, legal, and reputational damage. Security is not a cost centre — it is risk transfer.
Interview Questions — Secure Coding
Common Mistakes — Secure Coding
🎯 Key Takeaways
- ✓Fixing a security bug at design time costs 1× — in production after a breach it costs 100×. Security is a design activity, not a testing activity.
- ✓Allowlist validation (permit known-good) is always stronger than blocklist validation (deny known-bad) — attackers find encodings that bypass blocklists.
- ✓Output encoding is context-dependent: HTML, SQL, shell, URL, and JavaScript contexts each require different encoding. Encode for the final interpreter.
- ✓Parameterised queries completely eliminate SQL injection for value parameters. Dynamic identifiers (column names, table names) require explicit allowlisting.
- ✓Secrets never belong in source code, config files, or environment variables set at deploy time. Use a dedicated secrets manager with IAM-based access and short-lived credentials.
- ✓Lock files (package-lock.json, Pipfile.lock) pin exact transitive dependency versions — commit them. Run SCA tools (npm audit, pip-audit, Trivy) in CI to catch CVEs.
- ✓SAST (Semgrep, CodeQL) runs in seconds and catches injection flaws, hardcoded secrets, and insecure patterns without executing code. Add it to every CI pipeline.
- ✓STRIDE threat modelling at design time costs one meeting. Walking STRIDE through a data flow diagram systematically identifies threats before a line of code is written.
- ✓Error messages should be generic to clients but detailed internally. Log full context (stack trace, user ID, query) to your SIEM; return only an opaque error ID to users.
- ✓Constant-time comparison (hmac.compare_digest) is mandatory for security token comparison — timing attacks can reconstruct tokens one character at a time from response time differences.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.