Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT

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.

34 min May 2026

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 . 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.

💡 Note
This module targets developers who want to write secure code and security engineers who review it. No prior security expertise required — every concept builds from first principles.

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.

PrincipleWhat it meansViolation example
Least privilegeEvery component runs with the minimum permissions needed — nothing moreApp DB user has CREATE TABLE and DROP rights it never needs
Defense in depthMultiple independent controls so one failure does not cause a breachRelying on WAF alone; no input validation in application code
Fail securelyWhen errors occur, the system defaults to denying access, not granting itcatch(e) {} swallows auth error and lets request through
Separation of concernsAuthentication, authorisation, business logic, and data access in separate layersController directly queries DB and makes auth decisions inline
Minimise attack surfaceDisable unused features, endpoints, ports, and servicesAdmin API exposed on public internet "for convenience"
Never trust user inputAll data from outside the trust boundary is hostile until proven otherwiseUsing raw query params in SQL strings
Avoid security by obscuritySecurity must not depend on the attacker not knowing implementation details"Our API paths are random GUIDs so attackers won't find them"
Secure defaultsOut-of-the-box configuration is the most restrictive that still worksFramework ships with debug mode on; developers forget to disable it
Pro tip: When reviewing a PR, ask one question for each principle: Does this change widen the attack surface? Does it add a new trust boundary crossing without validation? One pass through this list catches 80% of design-level security issues.

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 Request

Validation 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 typeAttack if not validatedCorrect validation
File uploadAttacker uploads webshell.php disguised as image.jpgCheck magic bytes (not extension), restrict MIME types, scan with AV, store outside web root
Redirect URLOpen redirect: attacker links to your site then redirects to phishing pageAllowlist redirect targets or use opaque tokens mapped server-side
Integer inputInteger overflow, negative quantities, absurdly large valuesEnforce min/max bounds explicitly; never trust numeric range from client
JSON arraysMass assignment — extra fields overwrite internal fields like role or isAdminExplicitly declare accepted fields; use a strict schema; never spread raw body onto model
UnicodeHomograph attacks, case-folding bypasses, null byte injectionNormalise to NFC/NFKC before validation; reject null bytes; validate after normalisation
Pro tip: Validate at the system boundary — the HTTP handler or message queue consumer — not deep inside business logic. If validation lives in a utility function called from twelve places, it will eventually be called from a thirteenth place that forgets to invoke it.

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&#39;Brien &lt;script&gt;alert(1)&lt;/script&gt;
  → &lt; and &gt; prevent tag injection

HTML attribute context (double-quoted):
  O&#39;Brien &lt;script&gt;alert(1)&lt;/script&gt;
  → &#39; 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)
Pro tip: The rule of thumb: encode for the final interpreter, not the intermediate transport. If the data ends up in JavaScript inside an HTML page, HTML-encode the page AND JavaScript-encode the value within the script block. Double encoding where the contexts nest.

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 DB

Dynamic 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:

PracticeWhy it matters
Short TTL tokensAWS IAM roles with temporary credentials expire in minutes/hours — stolen creds have a small window
Secret rotationRotate DB passwords, API keys on a schedule (90 days max). Secrets manager can automate Lambda-triggered rotation
Audit access logsEvery read of a secret is logged — detect unusual access patterns before they become breaches
Least-privilege IAMEach service reads only the secrets it needs; a compromised microservice cannot read another service's secrets
Separate per-environmentProd secrets isolated from staging; dev secrets have no access to production data
Pre-commit hooksRun gitleaks or git-secrets before every commit to catch accidental hardcoding locally
Scan CI historytruffleHog 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: gitleaks

Dependency 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 faster

Lock 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 typeHow it worksDefence
TyposquattingRegister "reqeusts" or "colourama" — developers typo the installVerify package name, check download count and creation date before installing new packages
Dependency confusionPublish a public package with same name as an internal package — package manager pulls the public oneScope internal packages (@company/pkg), set up private registry with upstream proxy
Maintainer compromiseAttacker compromises a maintainer's npm/PyPI account and publishes malicious versionPin to exact versions + hashes in lock file, review diff before upgrading
Build system injectionMalicious code in build scripts, postinstall hooks, setup.pyRun npm install in a sandbox, review postinstall scripts, use --ignore-scripts for known-good packages
SBOM manipulationAttacker modifies software bill of materials to hide malicious componentGenerate 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 SPAs

Practical integration thresholds to avoid alert fatigue:

Finding severityActionTimeline
Critical (CVSS 9.0+)Block merge, page on-call, treat as P1 incidentFix within 24 hours
High (CVSS 7.0–8.9)Block merge to main, create tracked ticketFix within 7 days
Medium (CVSS 4.0–6.9)Create ticket, allow merge with acknowledgementFix within 30 days
Low / InformationalLog for review, do not block pipelineAddress in quarterly security sprint
Secret detectedBlock merge immediately, rotate the secretRotate before merge is possible
Pro tip: Start with SAST and secret scanning on day one — they run in seconds and catch the highest-impact issues cheapest. Add SCA on week one. Add DAST on a staging environment once you have a working deployment pipeline. Trying to add all three at once overwhelms teams with false positives.

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 expiry
Pro tip: The most common auth mistake in greenfield apps is rolling a custom session system when the framework already provides one. Flask-Login, Django's auth framework, Spring Security — use them. Every line of custom auth code is a potential CVE.

Cryptography 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 caseUse thisNever use
Password hashingArgon2id, bcrypt (cost ≥ 12), scryptMD5, SHA-1, SHA-256 (unsalted), custom
Symmetric encryptionAES-256-GCM, ChaCha20-Poly1305DES, 3DES, AES-ECB, AES-CBC without MAC
Key exchangeX25519 (ECDH), FFDHE-4096+RSA < 2048, DH < 2048, ECDH P-192
Digital signaturesEd25519, ECDSA P-256, RSA-PSSRSA-PKCS#1v1.5, DSA, ECDSA without deterministic k
Hashing / integritySHA-256, SHA-3, BLAKE2MD5, SHA-1, CRC32
Random numberssecrets module, os.urandom(), CSPRNGrandom module, Math.random(), time-seeded PRNGs
TLS versionTLS 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:

ThreatViolatesExampleControl
SpoofingAuthenticationAttacker forges another user's JWTStrong auth, signature verification
TamperingIntegrityAttacker modifies order total in transitHMAC, TLS, signed tokens
RepudiationNon-repudiationUser denies placing an order they madeImmutable audit logs, digital signatures
Information DisclosureConfidentialityError message reveals DB schemaGeneric errors, TLS, least-privilege DB access
Denial of ServiceAvailabilityAttacker floods endpoint, crashes serviceRate limiting, circuit breakers, autoscaling
Elevation of PrivilegeAuthorisationRegular user accesses admin endpointRBAC, 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).

Pro tip: The best time to threat model is when a design document is written, before any code exists. The second best time is during the first PR review for a new feature. By the time code is in production, architectural flaws cost 100× more to fix.

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 value

Log 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:

FindingSeverityImpactFix time
AWS RDS password hardcoded in config.py committed to GitHubCriticalFull database access — all 12,000 loan applications2 hours (rotate secret, move to Secrets Manager)
Loan amount parameter not validated — accepts negative valuesHighAttacker requests -$50,000 loan, credits their account30 minutes (add min=0 validator)
SQL query built with f-string for loan status filterHighSQL injection — dump all loan records1 hour (parameterise query)
Error responses include SQLAlchemy exception with table namesMediumReveals schema, helps attacker craft targeted SQLi15 minutes (generic error handler)
Session tokens generated with Python random moduleHighPredictable tokens — session hijacking20 minutes (replace with secrets.token_urlsafe)
No rate limiting on loan application endpointMediumAutomated enumeration of SSNs via timing differences2 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

Q: What is the difference between input validation and output encoding, and why do you need both?
Input validation enforces that incoming data conforms to expected type, format, length, and range before it enters the system. Output encoding transforms data into a safe representation for its destination context before it leaves the system. You need both because they protect against different attacks at different phases: validation stops malformed data from reaching business logic; encoding prevents valid data from being misinterpreted as code by interpreters like HTML parsers, SQL engines, or shells. A value that passes validation (say, a username containing single quotes) still needs SQL encoding before a query and HTML encoding before a web page.
Q: A developer argues that their application is safe from SQL injection because they use an ORM. How do you respond?
ORMs parameterise queries by default, which does eliminate most SQL injection. But three cases remain dangerous: (1) raw SQL escape hatches — SQLAlchemy's text(), Django's raw(), ActiveRecord's where() with string interpolation — all bypass ORM parameterisation; (2) second-order injection, where data safely stored gets retrieved and used in a later unparameterised query; (3) dynamic identifiers like ORDER BY column names that ORMs typically cannot parameterise and require allowlist validation. The ORM reduces risk significantly but does not eliminate the need to understand parameterisation or validate dynamic query parts.
Q: How do you securely store API keys that your application needs at runtime?
At runtime, secrets should be fetched from a dedicated secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) using the application's IAM identity — no static credentials needed to bootstrap. The application gets a short-lived token (AWS instance profile, Kubernetes ServiceAccount OIDC token) that authorises reading only the specific secrets it needs. Secrets are never in environment variables set at deploy time (visible in CI logs, Kubernetes manifests, process listings), never in config files committed to git, and never in Docker image layers. Access is logged, secrets are rotated on schedule, and each service has least-privilege access to only its own secrets.
Q: What is STRIDE and when would you use it?
STRIDE is a threat categorisation framework: Spoofing (forging identity), Tampering (modifying data), Repudiation (denying actions), Information Disclosure (exposing confidential data), Denial of Service (disrupting availability), and Elevation of Privilege (gaining unauthorised access). You use it during threat modelling sessions — typically when designing a new feature or reviewing a significant architecture change. You draw a data flow diagram, identify trust boundaries, then systematically ask which STRIDE threats apply at each data flow crossing a boundary. The output is a list of threats with assigned mitigations or accepted risks, tracked as engineering tickets. It transforms "is this secure?" from a vague question into a structured checklist.
Q: What should and should not be included in application error messages returned to clients?
Client error messages should include a generic description of what went wrong (enough for the user to understand the failure class), an opaque error ID that support staff can use to look up the full details in internal logs, and any user-actionable information (e.g., which field failed validation). They must never include stack traces, database query text, internal file paths, framework versions, exception class names, SQL error messages, or any data that helps an attacker map the system. Internally, log everything — full stack trace, user ID, request parameters, query that failed, server hostname — to a centralised log system the user cannot access. The principle: the client gets just enough to report the problem; the internal system gets everything needed to diagnose it.

Common Mistakes — Secure Coding

Validating on the client only
Why it happens: JavaScript form validation is bypassed by sending raw HTTP requests — any attacker uses curl or Burp Suite to skip it entirely. Developers sometimes mistake client-side validation for a security control.
Fix: All validation must happen server-side. Client-side validation is a UX nicety. The server is the only trust boundary — treat all incoming data as hostile regardless of what the client claims to have validated.
Using == for token comparison (timing attack)
Why it happens: String equality short-circuits on the first non-matching character. By measuring response time across many requests, an attacker can determine the correct token one character at a time, eventually brute-forcing it without ever seeing it.
Fix: Always use hmac.compare_digest() (Python), crypto.timingSafeEqual() (Node), or your language's equivalent constant-time comparison for any security-sensitive comparison: tokens, HMAC digests, session IDs.
Logging sensitive data
Why it happens: Developers add debug logging during development and forget to remove it, or log entire request bodies 'for debugging' — capturing passwords, session tokens, credit card numbers, and PII in plaintext log files accessible to anyone with log access.
Fix: Establish a logging policy: log the action and outcome, never the sensitive value. Audit log pipelines regularly with a secrets scanner. In structured logging, explicitly list allowed fields and reject everything else. Treat log files as sensitive data stores with appropriate access controls.
Catching exceptions and continuing silently
Why it happens: catch (e) {} or except: pass swallows errors silently. If an authentication check throws an exception and the catch block allows the request to proceed, the application silently bypasses auth — the most dangerous possible fail mode.
Fix: Follow the 'fail securely' principle: exceptions in security checks must deny access, not grant it. Log the exception with full context and return a generic error to the client. Never let an error in a security control default to open.
Storing secrets in environment variables set in CI/CD configuration
Why it happens: CI/CD systems like GitHub Actions, Jenkins, and CircleCI often log environment variables in build output, expose them to all jobs in a pipeline, or make them readable via the configuration API. Storing production secrets in CI environment variables creates broad exposure.
Fix: Use a secrets manager (AWS Secrets Manager, Vault) and have the application fetch secrets at runtime using its IAM identity. For CI/CD pipelines specifically, use the platform's secrets store (GitHub Encrypted Secrets, not environment variables in .yml files) and scope them to specific workflows and environments.

🎯 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.

💡 Note
Secure coding is the defensive foundation — you understand how vulnerabilities are born and prevented. In Module 21: Penetration Testing Methodology, you switch to the offensive side: how professional pentesters plan and execute engagements, the five-phase methodology, rules of engagement, scoping, reporting, and the legal and ethical framework that separates authorised security testing from criminal hacking.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...