Skip to main content

Secure coding basics

Most security vulnerabilities in web applications come from a small set of common mistakes. SQL injection, cross-site scripting, broken authentication — these are well-documented vulnerability classes, yet they keep appearing in new code because developers aren't taught to avoid them.

The good news: you don't need to become a security expert to avoid these mistakes. Understanding the top 10 vulnerability classes and applying consistent patterns to prevent them will eliminate the vast majority of security issues in your code.

This chapter covers the OWASP Top 10, practical prevention techniques, and a checklist your team can use during code review.

The OWASP Top 10

OWASP (Open Web Application Security Project) maintains a list of the ten most critical web application security risks. The list is updated every few years based on real-world data from security assessments.

Here's the current list (2021) with practical context for small teams:

A01: Broken access control

Users can access data or functions they shouldn't. The most common vulnerability class.

What it looks like:

# BAD: Anyone can view any user's profile by changing the ID
@app.route('/api/users/<user_id>/profile')
def get_profile(user_id):
return User.query.get(user_id).to_dict()

The fix:

# GOOD: Check that the requesting user can access this profile
@app.route('/api/users/<user_id>/profile')
@login_required
def get_profile(user_id):
if current_user.id != int(user_id) and not current_user.is_admin:
abort(403)
return User.query.get(user_id).to_dict()

Prevention patterns:

  • Default to deny. Explicitly grant access, don't explicitly deny.
  • Check authorization on every request, not just in the UI.
  • Use indirect references (UUIDs) instead of sequential IDs.
  • Log access control failures.

A02: Cryptographic failures

Sensitive data exposed due to weak or missing encryption.

Common mistakes:

  • Storing passwords in plain text or with weak hashing (MD5, SHA1)
  • Transmitting data over HTTP instead of HTTPS
  • Hardcoding encryption keys in source code
  • Using deprecated cryptographic algorithms

What to do:

# BAD: MD5 for passwords
password_hash = hashlib.md5(password.encode()).hexdigest()

# GOOD: bcrypt with proper cost factor
from bcrypt import hashpw, gensalt, checkpw
password_hash = hashpw(password.encode(), gensalt(rounds=12))

Prevention patterns:

  • Use bcrypt, Argon2, or scrypt for password hashing. Never MD5 or SHA1.
  • Enforce HTTPS everywhere. Redirect HTTP to HTTPS.
  • Store secrets in environment variables or a secrets manager (Passwork, HashiCorp Vault), never in code.
  • Use TLS 1.2 or higher for data in transit.

A03: Injection

Untrusted data sent to an interpreter as part of a command or query. SQL injection is the classic example, but this also covers NoSQL, OS commands, LDAP, and others.

SQL injection example:

# BAD: String concatenation with user input
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)

# GOOD: Parameterized query
query = "SELECT * FROM users WHERE username = %s"
cursor.execute(query, (username,))

Command injection example:

# BAD: User input in shell command
os.system(f"ping {host}")

# GOOD: Use subprocess with argument list
subprocess.run(["ping", "-c", "4", host], capture_output=True)

Prevention patterns:

  • Use parameterized queries (prepared statements) for all database access.
  • Use ORMs that handle parameterization automatically.
  • Validate and sanitize all input.
  • Never pass user input directly to system commands.

A04: Insecure design

Security problems baked into the architecture, not just implementation bugs.

Examples:

  • Password recovery that emails the actual password (means you're storing it retrievably)
  • "Security questions" that are easily researched (mother's maiden name, first pet)
  • No rate limiting on authentication endpoints
  • Allowing unlimited file uploads without size or type restrictions

Prevention patterns:

  • Threat model during design, not after.
  • Consider abuse cases: "What if someone tries to break this?"
  • Apply defense in depth — multiple layers of protection.
  • Follow established security design patterns.

A05: Security misconfiguration

Default credentials, unnecessary features enabled, verbose error messages, missing security headers.

Common issues:

  • Default admin passwords not changed
  • Debug mode enabled in production
  • Directory listing enabled
  • Missing security headers (CSP, X-Frame-Options)
  • Unnecessary services running

Prevention patterns:

  • Automate configuration with Infrastructure as Code.
  • Remove unused features and frameworks.
  • Use security header checkers (securityheaders.com).
  • Implement different configurations for dev/staging/production.

A06: Vulnerable and outdated components

Using libraries or frameworks with known vulnerabilities.

The problem: Your application might be secure, but if you're using a library with a published CVE, attackers have a ready-made exploit.

Prevention patterns:

  • Keep dependencies updated.
  • Use Dependabot, Snyk, or npm audit to monitor for vulnerabilities.
  • Remove unused dependencies.
  • Subscribe to security advisories for your stack.

This is covered in depth in the vulnerability management chapter.

A07: Identification and authentication failures

Weak passwords, credential stuffing, session management bugs.

Common issues:

  • No brute force protection
  • Session tokens in URLs
  • Sessions that don't expire
  • Password reset tokens that don't expire

Prevention patterns:

  • Implement MFA (covered earlier in this course).
  • Use established session management from your framework.
  • Enforce password complexity requirements.
  • Rate limit authentication attempts.
  • Invalidate sessions on logout and password change.

A08: Software and data integrity failures

Code or data modified without verification. Includes insecure deserialization and CI/CD pipeline attacks.

Examples:

  • Deserializing untrusted data (pickle, Java serialization)
  • Loading JavaScript from untrusted CDNs without integrity checks
  • CI/CD pipelines that run arbitrary code from pull requests

Prevention patterns:

  • Use Subresource Integrity (SRI) for external scripts.
  • Avoid deserializing untrusted data. If necessary, use safe alternatives (JSON instead of pickle).
  • Sign and verify code and data integrity.
  • Secure your CI/CD pipeline.

A09: Security logging and monitoring failures

You can't detect or respond to attacks if you're not logging.

What to log:

  • Authentication successes and failures
  • Authorization failures
  • Input validation failures
  • Application errors

What not to log:

  • Passwords or session tokens
  • Credit card numbers
  • Personal data beyond what's necessary

Prevention patterns:

  • Implement centralized logging.
  • Set up alerts for suspicious patterns.
  • Include enough context to investigate (timestamp, user, IP, action).
  • Protect logs from tampering.

A10: Server-Side Request Forgery (SSRF)

Application fetches URLs provided by users without validation, allowing attackers to access internal resources.

Example:

# BAD: Fetching user-provided URL
@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
return requests.get(url).text # Can access internal services!

Prevention patterns:

  • Validate and sanitize URL input.
  • Use allowlists for permitted domains.
  • Block requests to internal IP ranges (10.x, 172.16.x, 192.168.x, 169.254.x).
  • Use network segmentation so the web server can't reach internal services.

Input validation

Most vulnerabilities involve untrusted input being processed unsafely. Input validation is your first line of defense.

Validation principles

Validate on the server, not just the client. Client-side validation improves UX but provides zero security. Anyone can bypass JavaScript validation by sending requests directly.

Validate against an allowlist when possible. Define what's acceptable, reject everything else.

# BAD: Blocklist approach (trying to block bad things)
if "<script>" in input:
reject()

# GOOD: Allowlist approach (only accept known good patterns)
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
reject()

Validate type, length, format, and range.

def validate_user_input(data):
errors = []

# Type validation
if not isinstance(data.get('age'), int):
errors.append("Age must be an integer")

# Range validation
if data.get('age', 0) < 0 or data.get('age', 0) > 150:
errors.append("Age must be between 0 and 150")

# Length validation
if len(data.get('username', '')) > 50:
errors.append("Username too long")

# Format validation
if not re.match(r'^[a-zA-Z0-9_]+$', data.get('username', '')):
errors.append("Username contains invalid characters")

return errors

Common validation patterns

Email addresses:

import re
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, email):
raise ValidationError("Invalid email format")

URLs:

from urllib.parse import urlparse

def validate_url(url):
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValidationError("Invalid URL scheme")
if not parsed.netloc:
raise ValidationError("Invalid URL")

File uploads:

ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB

def validate_file(file):
# Check extension
ext = file.filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValidationError(f"File type not allowed: {ext}")

# Check size
file.seek(0, 2) # Seek to end
size = file.tell()
file.seek(0) # Reset
if size > MAX_FILE_SIZE:
raise ValidationError("File too large")

# Check magic bytes (file signature)
# Don't trust the extension alone

Output encoding

Validation prevents bad data from entering your system. Output encoding prevents stored data from being executed in a harmful context.

Context-specific encoding

HTML context:

# BAD: Raw output in HTML
return f"<p>Welcome, {username}</p>"

# GOOD: HTML-encode the output
from markupsafe import escape
return f"<p>Welcome, {escape(username)}</p>"

# Or use a template engine that auto-escapes (Jinja2, Django templates)

JavaScript context:

# BAD: User data in JavaScript
return f'<script>var name = "{username}";</script>'

# GOOD: JSON-encode for JavaScript context
import json
return f'<script>var name = {json.dumps(username)};</script>'

URL context:

# BAD: Raw value in URL
return f'<a href="/search?q={query}">Search</a>'

# GOOD: URL-encode
from urllib.parse import quote
return f'<a href="/search?q={quote(query)}">Search</a>'

SQL context: Always use parameterized queries. No amount of encoding substitutes for proper query parameterization.

CSRF protection

Cross-Site Request Forgery tricks a user's browser into making unwanted requests to a site where they're authenticated. If you're logged into your bank, a malicious site could submit a transfer request on your behalf.

How CSRF works

1. User logs into bank.com, gets session cookie
2. User visits malicious-site.com (in another tab)
3. Malicious site contains: <img src="https://bank.com/transfer?to=attacker&amount=1000">
4. Browser sends request to bank.com WITH the session cookie
5. Bank processes the transfer — user is authenticated

CSRF tokens

The standard defense: include a secret token in forms that the attacker can't guess.

Server generates token:

import secrets

def get_csrf_token(session):
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(32)
return session['csrf_token']

Include in forms:

<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- other fields -->
</form>

Validate on submission:

@app.route('/transfer', methods=['POST'])
def transfer():
if request.form.get('csrf_token') != session.get('csrf_token'):
abort(403, 'Invalid CSRF token')
# Process the request

Framework support:

Most frameworks handle CSRF automatically:

# Django - enabled by default
# Just include {% csrf_token %} in forms

# Flask-WTF
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
// Express.js with csurf
const csrf = require('csurf');
app.use(csrf({ cookie: true }));

app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});

SameSite cookies

Modern browsers support the SameSite cookie attribute, which prevents cookies from being sent with cross-site requests.

# Set SameSite attribute
response.set_cookie(
'session',
value=session_id,
httponly=True,
secure=True,
samesite='Lax' # or 'Strict'
)

SameSite values:

  • Strict — Cookie never sent with cross-site requests. Safest, but breaks legitimate links from other sites.
  • Lax — Cookie sent with top-level navigation (clicking links) but not with embedded requests (images, iframes, AJAX). Good default.
  • None — Cookie sent with all requests. Must use Secure flag. Only for legitimate cross-site use cases.

Recommendation: Use SameSite=Lax plus CSRF tokens for defense in depth. Don't rely on SameSite alone — older browsers don't support it.

CSRF for APIs

For APIs using tokens (not cookies), CSRF isn't an issue — the attacker can't steal the token from another site.

But if your API uses cookie-based authentication:

  • Require a custom header (e.g., X-Requested-With) — browsers don't send custom headers in simple cross-origin requests
  • Or require the CSRF token in a header
// Frontend sends CSRF token in header
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(data)
});

API security

If you're building REST APIs, there are additional security considerations.

Rate limiting

Without rate limiting, attackers can:

  • Brute force passwords
  • Scrape your data
  • DoS your service
  • Run up your infrastructure costs

Express.js:

const rateLimit = require('express-rate-limit');

// General rate limit
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later'
});
app.use('/api/', limiter);

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 attempts per hour
message: 'Too many login attempts'
});
app.use('/api/login', authLimiter);

Flask:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(app, key_func=get_remote_address)

@app.route('/api/login', methods=['POST'])
@limiter.limit("5 per hour")
def login():
# ...

@app.route('/api/data')
@limiter.limit("100 per minute")
def get_data():
# ...

Django:

# Using django-ratelimit
from django_ratelimit.decorators import ratelimit

@ratelimit(key='ip', rate='5/h', method='POST', block=True)
def login(request):
# ...

Rate limiting strategies:

  • By IP address (can be bypassed with proxies)
  • By user account (for authenticated endpoints)
  • By API key
  • Combination of the above

CORS configuration

CORS (Cross-Origin Resource Sharing) controls which websites can call your API from JavaScript.

The problem:

// From evil-site.com
fetch('https://your-api.com/user/data')
.then(r => r.json())
.then(data => sendToAttacker(data));

Without CORS headers, browsers block this. With misconfigured CORS, attackers can steal data from authenticated users.

Bad configuration:

# DON'T DO THIS - allows any site to access your API
@app.after_request
def add_cors(response):
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Credentials'] = 'true' # DANGEROUS with *
return response

Good configuration:

# Flask-CORS
from flask_cors import CORS

# Allow only your frontend
CORS(app, origins=['https://your-frontend.com'], supports_credentials=True)
// Express.js
const cors = require('cors');

app.use(cors({
origin: 'https://your-frontend.com',
credentials: true
}));

CORS rules:

  • Never use Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true
  • Allowlist specific origins, don't reflect the Origin header blindly
  • Be restrictive with allowed methods and headers

API authentication

API keys:

@app.before_request
def check_api_key():
if request.path.startswith('/api/'):
api_key = request.headers.get('X-API-Key')
if not api_key or not validate_api_key(api_key):
abort(401)

API key best practices:

  • Generate long, random keys (32+ characters)
  • Store hashed in database (like passwords)
  • Allow key rotation without downtime
  • Log key usage for auditing
  • Implement key scopes/permissions

JWT security

JSON Web Tokens are commonly used for API authentication. They're also commonly misconfigured.

How JWT works

Header.Payload.Signature

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjM0fQ.signature

The server signs the token with a secret key. The client sends it back with requests. The server verifies the signature to trust the payload.

Common JWT mistakes

Mistake 1: Algorithm confusion (alg:none)

Some JWT libraries accept alg: "none", which means no signature verification.

# Attacker crafts token with no signature
# Header: {"alg": "none"}
# Payload: {"user_id": "admin"}
# Signature: (empty)

Fix: Always validate the algorithm explicitly:

import jwt

# BAD: Library might accept "none"
data = jwt.decode(token, SECRET_KEY)

# GOOD: Explicitly specify allowed algorithms
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])

Mistake 2: Weak or leaked secrets

# BAD
SECRET_KEY = 'secret' # Easily guessable

# GOOD
SECRET_KEY = os.environ.get('JWT_SECRET') # At least 256 bits of entropy

Mistake 3: Storing in localStorage

// BAD: XSS can steal the token
localStorage.setItem('token', jwt);

// BETTER: HttpOnly cookie (not accessible to JavaScript)
// Set from server with HttpOnly flag

If you must use localStorage, implement additional protections:

  • Short token expiration
  • Token rotation
  • Fingerprinting

Mistake 4: No expiration

# BAD: Token valid forever
payload = {'user_id': user.id}
token = jwt.encode(payload, SECRET_KEY)

# GOOD: Short expiration
from datetime import datetime, timedelta

payload = {
'user_id': user.id,
'exp': datetime.utcnow() + timedelta(hours=1)
}
token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')

Mistake 5: Sensitive data in payload

JWTs are encoded, not encrypted. Anyone can decode the payload.

# BAD: Don't put secrets in JWT
payload = {
'user_id': 123,
'credit_card': '4111111111111111' # Visible to anyone!
}

# GOOD: Only include non-sensitive identifiers
payload = {
'user_id': 123,
'roles': ['user']
}

JWT best practices

import jwt
from datetime import datetime, timedelta

def create_token(user_id, roles):
payload = {
'sub': user_id, # Subject
'roles': roles,
'iat': datetime.utcnow(), # Issued at
'exp': datetime.utcnow() + timedelta(hours=1), # Expiration
'jti': secrets.token_hex(16) # Unique token ID (for revocation)
}
return jwt.encode(payload, SECRET_KEY, algorithm='HS256')

def verify_token(token):
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=['HS256'], # Explicit algorithm
options={'require': ['exp', 'sub']} # Required claims
)
return payload
except jwt.ExpiredSignatureError:
raise AuthError('Token expired')
except jwt.InvalidTokenError:
raise AuthError('Invalid token')

Token storage comparison

StorageXSS vulnerableCSRF vulnerableRecommendation
localStorageYesNoAvoid for sensitive tokens
sessionStorageYesNoSlightly better (cleared on tab close)
HttpOnly cookieNoYes (mitigate with SameSite)Best for web apps
Memory onlyNoNoBest security, but lost on refresh

For most web apps: Use HttpOnly cookies with SameSite=Lax.

For SPAs that need token access: Use short-lived access tokens in memory + HttpOnly refresh tokens in cookies.

Secure handling of secrets

Secrets in source code are one of the most common security issues in small companies.

What counts as a secret

  • API keys
  • Database passwords
  • Encryption keys
  • OAuth client secrets
  • JWT signing keys
  • Service account credentials
  • SSH private keys

Where secrets should NOT be

  • Source code files
  • Git repositories (even if later deleted — Git history preserves them)
  • Configuration files committed to version control
  • Log files
  • Error messages
  • Client-side code (JavaScript, mobile apps)

Where secrets SHOULD be

Environment variables:

# config.py
import os

DATABASE_URL = os.environ.get('DATABASE_URL')
API_KEY = os.environ.get('API_KEY')

Secrets manager (recommended):

# Using Passwork CLI or API
import subprocess
import json

def get_secret(secret_id):
result = subprocess.run(
['passwork-cli', 'get', secret_id, '--format', 'json'],
capture_output=True
)
return json.loads(result.stdout)['password']

DATABASE_PASSWORD = get_secret('database-production')

Cloud secrets managers:

# AWS Secrets Manager
import boto3

def get_secret(secret_name):
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId=secret_name)
return response['SecretString']

Checking for secrets in code

Use automated tools to scan for accidentally committed secrets:

# Install git-secrets
brew install git-secrets

# Add common patterns
git secrets --register-aws

# Scan repository
git secrets --scan

# Install as pre-commit hook
git secrets --install

Other tools:

  • truffleHog — Scans Git history for high-entropy strings
  • gitleaks — Fast scanning for secrets in Git repos
  • detect-secrets — Yelp's tool for preventing secrets in code

Common vulnerabilities by language

JavaScript/Node.js

Prototype pollution:

// BAD: Merging user input into objects
Object.assign(config, userInput);

// GOOD: Create new object, validate keys
const sanitized = {};
for (const key of allowedKeys) {
if (key in userInput) {
sanitized[key] = userInput[key];
}
}

eval() and Function():

// BAD: Never use eval with user input
eval(userInput);

// GOOD: Use JSON.parse for data, avoid eval entirely
const data = JSON.parse(userInput);

NoSQL injection (MongoDB):

// BAD: Query from user input
db.users.find({ username: req.body.username });
// Attacker sends: { "$gt": "" } to match all users

// GOOD: Explicitly expect a string
const username = String(req.body.username);
db.users.find({ username: username });

Python

Unsafe deserialization:

# BAD: pickle with untrusted data
import pickle
data = pickle.loads(user_input) # Can execute arbitrary code!

# GOOD: Use JSON
import json
data = json.loads(user_input)

Template injection:

# BAD: User input in template string
from jinja2 import Template
Template(user_input).render() # SSTI vulnerability

# GOOD: User input as template variable
Template("Hello {{ name }}").render(name=user_input)

PHP

Local file inclusion:

// BAD: User input in include
include($_GET['page'] . '.php');

// GOOD: Allowlist of permitted pages
$allowed = ['home', 'about', 'contact'];
if (in_array($_GET['page'], $allowed)) {
include($_GET['page'] . '.php');
}

Type juggling:

// BAD: Loose comparison with user input
if ($password == $user_password) { ... }

// GOOD: Strict comparison
if ($password === $user_password) { ... }

// BEST: Use password_verify for passwords
if (password_verify($password, $hash)) { ... }

Java

XML External Entity (XXE):

// BAD: Default XML parser settings
DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = db.parse(userInput);

// GOOD: Disable external entities
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DocumentBuilder db = dbf.newDocumentBuilder();

Insecure deserialization:

// BAD: Deserializing untrusted data
ObjectInputStream ois = new ObjectInputStream(userInputStream);
Object obj = ois.readObject(); // Can execute arbitrary code

// GOOD: Use JSON or validate object types

Security headers

Configure your web server or application to send security headers:

# Content Security Policy - controls what resources can load
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'

# Prevent clickjacking
X-Frame-Options: DENY

# Prevent MIME type sniffing
X-Content-Type-Options: nosniff

# Enable browser XSS filter
X-XSS-Protection: 1; mode=block

# Force HTTPS
Strict-Transport-Security: max-age=31536000; includeSubDomains

# Control referrer information
Referrer-Policy: strict-origin-when-cross-origin

# Prevent browser features you don't use
Permissions-Policy: geolocation=(), microphone=(), camera=()

Express.js example:

const helmet = require('helmet');
app.use(helmet());

Django example:

# settings.py
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = 'DENY'
SECURE_HSTS_SECONDS = 31536000

Secure coding checklist

Use this during code review:

Authentication and authorization

  • Passwords hashed with bcrypt/Argon2 (not MD5/SHA1)
  • Session tokens are random, long, and HTTP-only
  • Authorization checked on every request
  • Failed login attempts are rate-limited
  • Password reset tokens expire

Input and output

  • All input validated on the server
  • Validation uses allowlists where possible
  • SQL uses parameterized queries
  • HTML output is encoded
  • File uploads validated (type, size, content)

Secrets

  • No secrets in source code
  • Secrets loaded from environment or secrets manager
  • API keys have minimum required permissions
  • Secrets rotatable without code deployment

Configuration

  • Debug mode disabled in production
  • Error messages don't expose internals
  • Security headers configured
  • HTTPS enforced

Dependencies

  • Dependencies up to date
  • No known vulnerable versions
  • Unused dependencies removed

Workshop: secure your codebase

Block 2-3 hours for this assessment and initial fixes.

Part 1: Secrets scan (30 minutes)

  1. Install a secrets scanner:
pip install trufflehog
# or
brew install gitleaks
  1. Scan your repository:
trufflehog git file://. --only-verified
# or
gitleaks detect --source .
  1. Document any findings
  2. Rotate any exposed secrets immediately
  3. Remove secrets from code and Git history if necessary

Deliverable: List of found secrets and remediation status

Part 2: Dependency audit (30 minutes)

  1. Run your language's audit tool:
# Node.js
npm audit

# Python
pip-audit

# Ruby
bundle audit

# PHP
composer audit
  1. Document vulnerable packages
  2. Update what you can immediately
  3. Create tickets for updates that need testing

Deliverable: Dependency vulnerability report

Part 3: Code review for OWASP Top 10 (60 minutes)

Pick 3-5 critical endpoints (login, registration, password reset, data access) and review for:

  • SQL injection
  • XSS vulnerabilities
  • Missing authorization checks
  • Insecure direct object references
  • Sensitive data exposure

Deliverable: List of findings with severity ratings

Part 4: Security headers check (15 minutes)

  1. Visit securityheaders.com
  2. Scan your production URL
  3. Document missing headers
  4. Implement missing headers or create ticket

Deliverable: Security headers report and remediation plan

Part 5: Create team checklist (30 minutes)

  1. Adapt the secure coding checklist above for your stack
  2. Add language-specific items
  3. Share with team
  4. Add to code review process

Deliverable: Team-specific secure coding checklist

Common mistakes

Mistake 1: Security through obscurity

Hiding things (non-standard ports, obfuscated URLs) instead of securing them. Obscurity adds friction for attackers but isn't real security.

Fix: Secure by design. Assume attackers know everything about your system except secrets.

Mistake 2: Client-side validation only

Trusting that JavaScript validation will prevent bad input.

Fix: Always validate on the server. Client-side validation is for UX only.

Mistake 3: DIY cryptography

Writing your own encryption, hashing, or token generation.

Fix: Use established libraries. Use bcrypt for passwords. Use your framework's session management. Use standard JWT libraries.

Mistake 4: Error messages that help attackers

"Invalid username" vs "Invalid password" tells attackers which usernames exist.

Fix: Generic error messages: "Invalid credentials."

Mistake 5: Logging secrets

Dumping request bodies to logs, including passwords and API keys.

Fix: Filter sensitive fields before logging. Never log passwords, tokens, or keys.

Tools for secure development

Static analysis (SAST)

ToolLanguagesFree tier
SemgrepManyYes
BanditPythonOpen source
BrakemanRuby/RailsOpen source
ESLint security pluginsJavaScriptOpen source
SonarQubeManyCommunity edition
CodeQLManyFree for open source

Secrets scanning

ToolDescription
git-secretsAWS tool, pre-commit hook
gitleaksFast, configurable
truffleHogDeep scanning, verified secrets
detect-secretsYelp's tool, good for CI

Dependency scanning

ToolEcosystem
npm auditNode.js
pip-auditPython
bundler-auditRuby
SnykMultiple, free tier
DependabotGitHub, multiple

Talking to leadership

If someone asks why you're spending time on secure coding:

"I'm reviewing our code for common security vulnerabilities — the same issues that cause most breaches. Things like SQL injection and cross-site scripting have been known for decades, but they still appear in code all the time. I'm also scanning for any secrets accidentally committed to our repository and checking our dependencies for known vulnerabilities. This reduces the risk of a breach and saves us from more expensive fixes later."

Short version: "I'm finding and fixing security issues in our code before attackers find them."

Self-check

Code security

  • Understand the OWASP Top 10
  • Can identify SQL injection vulnerabilities
  • Can identify XSS vulnerabilities
  • Know how to properly hash passwords
  • Know how to validate input

Secrets management

  • Scanned repository for secrets
  • No secrets in source code
  • Secrets loaded from environment or secrets manager
  • Pre-commit hook to prevent new secrets

Dependencies

  • Run dependency audit
  • No critical vulnerabilities in dependencies
  • Process for keeping dependencies updated

Process

  • Secure coding checklist exists
  • Security considered in code review
  • Static analysis tool in place (or planned)

Check off at least 10 of 14 items before moving on.

What's next

You understand the common vulnerabilities and how to prevent them in code. Next chapter: security requirements management — how to define, track, and verify security requirements using OWASP ASVS and integrate them into your development workflow.