Software Development Security Best Practices: Complete Guide
Essential security practices every software development team should follow to build secure, reliable applications. Covers OWASP Top 10, secure coding, and DevSecOps.
Security is not a feature—it's a foundation. This comprehensive guide covers essential security practices that every development team should integrate throughout the software development lifecycle, from design through deployment and beyond.
The Security-First Mindset
In modern software development, security is everyone's responsibility. The cost of fixing a vulnerability increases exponentially the later it's discovered—finding an issue in production costs 100x more than catching it during design.
The earlier you find security issues, the cheaper they are to fix. A vulnerability found in design costs $500 to fix; the same vulnerability in production costs $15,000+.
Secure Development Lifecycle (SDL)
Integrate security into every phase of development, not as an afterthought but as a core practice.
Traditional vs DevSecOps Approach
| Aspect | Traditional Security | DevSecOps |
|---|---|---|
| Timing | Before deployment (late stage) | Throughout development (continuous) |
| Responsibility | Dedicated security team | Shared by all team members |
| Release Impact | Slows releases, creates bottlenecks | Enables fast, secure releases |
| Automation | Manual reviews and audits | Automated scanning in CI/CD |
| Feedback Loop | Late feedback, high rework cost | Immediate feedback, low fix cost |
| Culture | Security as gatekeeper | Security as enabler |
OWASP Top 10: Critical Vulnerabilities
The OWASP Top 10 represents the most critical security risks to web applications. Every developer should understand and prevent these vulnerabilities.
OWASP Top 10 Prevention Guide
| Vulnerability | Description | Prevention |
|---|---|---|
| A01: Broken Access Control | Users act outside intended permissions | Deny by default, enforce ownership, rate limiting |
| A02: Cryptographic Failures | Exposure of sensitive data | Encrypt in transit/rest, strong algorithms, key rotation |
| A03: Injection | Untrusted data sent to interpreter | Parameterized queries, input validation, escaping |
| A04: Insecure Design | Missing security controls | Threat modeling, secure design patterns, security requirements |
| A05: Security Misconfiguration | Insecure default configurations | Hardened configs, minimal install, automated checks |
| A06: Vulnerable Components | Using components with known vulns | SCA scanning, dependency updates, SBOM |
| A07: Auth Failures | Broken authentication mechanisms | MFA, strong passwords, rate limiting, secure sessions |
| A08: Integrity Failures | Code/data integrity not verified | Digital signatures, integrity checks, secure CI/CD |
| A09: Logging Failures | Insufficient logging and monitoring | Audit logs, alerting, log integrity, SIEM integration |
| A10: SSRF | Server makes requests to attacker-controlled URLs | URL allowlists, disable redirects, network segmentation |
Secure Coding Practices
Writing secure code requires understanding common vulnerability patterns and applying defensive programming techniques.
Input Validation and Sanitization
Never trust user input. All input is potentially malicious until validated. Validate on the server side—client-side validation is for UX, not security.
# Secure input validation example
import re
from typing import Optional
from dataclasses import dataclass
from enum import Enum
class ValidationError(Exception):
"""Custom validation error with safe messages."""
pass
class InputType(Enum):
EMAIL = "email"
USERNAME = "username"
PHONE = "phone"
URL = "url"
@dataclass
class ValidationRule:
pattern: str
min_length: int
max_length: int
error_message: str
# Define strict validation rules
VALIDATION_RULES = {
InputType.EMAIL: ValidationRule(
pattern=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
min_length=5,
max_length=254,
error_message="Invalid email format"
),
InputType.USERNAME: ValidationRule(
pattern=r'^[a-zA-Z][a-zA-Z0-9_]{2,29}$',
min_length=3,
max_length=30,
error_message="Username must start with letter, 3-30 chars"
),
InputType.PHONE: ValidationRule(
pattern=r'^\+?[1-9]\d{1,14}$',
min_length=10,
max_length=15,
error_message="Invalid phone number format"
)
}
def validate_input(value: str, input_type: InputType) -> str:
"""
Validate and sanitize input based on type.
Returns sanitized value or raises ValidationError.
"""
if not value or not isinstance(value, str):
raise ValidationError("Input is required")
# Strip and normalize
value = value.strip()
rule = VALIDATION_RULES.get(input_type)
if not rule:
raise ValidationError("Unknown input type")
# Check length
if len(value) < rule.min_length or len(value) > rule.max_length:
raise ValidationError(rule.error_message)
# Check pattern
if not re.match(rule.pattern, value):
raise ValidationError(rule.error_message)
return value
def sanitize_html(content: str) -> str:
"""
Remove potentially dangerous HTML/JS content.
Use a proper library like bleach in production.
"""
import html
# Escape HTML entities
sanitized = html.escape(content)
# Remove script-related patterns
dangerous_patterns = [
r'javascript:',
r'on\w+\s*=',
r'
SQL Injection Prevention
Use Parameterized Queries
Never concatenate user input into SQL queries. Always use parameterized queries or prepared statements.
Use ORM Safely
ORMs provide protection, but raw queries within ORMs can still be vulnerable. Review all raw SQL usage.
Apply Least Privilege
Database users should have minimum required permissions. Don't use admin accounts for application connections.
# SQL Injection Prevention Examples
import sqlite3
from typing import List, Optional, Dict, Any
class SecureDatabase:
"""Database class with secure query methods."""
def __init__(self, db_path: str):
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
# ❌ VULNERABLE - Never do this!
def get_user_vulnerable(self, username: str):
"""DANGEROUS: SQL Injection vulnerable."""
query = f"SELECT * FROM users WHERE username = '{username}'"
return self.conn.execute(query).fetchone()
# ✅ SECURE - Use parameterized queries
def get_user_secure(self, username: str) -> Optional[Dict[str, Any]]:
"""SAFE: Uses parameterized query."""
query = "SELECT id, username, email FROM users WHERE username = ?"
result = self.conn.execute(query, (username,)).fetchone()
return dict(result) if result else None
# ✅ SECURE - Multiple parameters
def search_users(self, name: str, role: str, limit: int = 10) -> List[Dict]:
"""SAFE: Multiple parameterized values."""
query = """
SELECT id, username, email, role
FROM users
WHERE name LIKE ? AND role = ?
LIMIT ?
"""
# Use wildcards safely in the parameter, not the query
results = self.conn.execute(query, (f"%{name}%", role, limit))
return [dict(row) for row in results.fetchall()]
# ✅ SECURE - Dynamic column selection (whitelisted)
def get_users_sorted(self, sort_column: str, order: str = "ASC"):
"""SAFE: Whitelist approach for dynamic SQL parts."""
# Whitelist allowed columns
allowed_columns = {"id", "username", "created_at", "email"}
allowed_orders = {"ASC", "DESC"}
if sort_column not in allowed_columns:
raise ValueError(f"Invalid sort column: {sort_column}")
if order.upper() not in allowed_orders:
raise ValueError(f"Invalid sort order: {order}")
# Safe because values are validated against whitelist
query = f"SELECT * FROM users ORDER BY {sort_column} {order}"
return self.conn.execute(query).fetchall()
Authentication Security
# Secure password handling
import secrets
import hashlib
from typing import Tuple
import bcrypt
class PasswordSecurity:
"""Secure password hashing and verification."""
# Cost factor - increase for more security (slower)
BCRYPT_ROUNDS = 12
@staticmethod
def hash_password(password: str) -> str:
"""
Hash password using bcrypt with salt.
Returns the hash string for storage.
"""
# Encode password to bytes
password_bytes = password.encode('utf-8')
# Generate salt and hash
salt = bcrypt.gensalt(rounds=PasswordSecurity.BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
@staticmethod
def verify_password(password: str, hashed: str) -> bool:
"""
Verify password against stored hash.
Uses constant-time comparison to prevent timing attacks.
"""
try:
password_bytes = password.encode('utf-8')
hashed_bytes = hashed.encode('utf-8')
return bcrypt.checkpw(password_bytes, hashed_bytes)
except Exception:
return False
@staticmethod
def check_password_strength(password: str) -> Tuple[bool, List[str]]:
"""
Check if password meets security requirements.
Returns (is_valid, list_of_issues).
"""
issues = []
if len(password) < 12:
issues.append("Password must be at least 12 characters")
if len(password) > 128:
issues.append("Password must not exceed 128 characters")
if not any(c.isupper() for c in password):
issues.append("Password must contain uppercase letter")
if not any(c.islower() for c in password):
issues.append("Password must contain lowercase letter")
if not any(c.isdigit() for c in password):
issues.append("Password must contain a number")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
issues.append("Password must contain a special character")
# Check against common passwords
common_passwords = {"password", "123456", "qwerty", "admin"}
if password.lower() in common_passwords:
issues.append("Password is too common")
return len(issues) == 0, issues
@staticmethod
def generate_secure_token(length: int = 32) -> str:
"""Generate cryptographically secure random token."""
return secrets.token_urlsafe(length)
@staticmethod
def generate_reset_token() -> Tuple[str, str]:
"""
Generate password reset token.
Returns (token_for_user, hash_for_storage).
"""
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
Cross-Site Scripting (XSS) Prevention
// XSS Prevention in JavaScript
// ❌ VULNERABLE - Never do this!
function displayUserInputDangerous(userInput) {
document.getElementById('output').innerHTML = userInput;
// Attacker can inject:
}
// ✅ SECURE - Use textContent for plain text
function displayUserInputSafe(userInput) {
document.getElementById('output').textContent = userInput;
// Scripts are rendered as text, not executed
}
// ✅ SECURE - Sanitize if HTML is needed
function displaySanitizedHTML(userInput) {
const sanitized = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
});
document.getElementById('output').innerHTML = sanitized;
}
// ✅ SECURE - Create elements programmatically
function createUserElement(username, email) {
const div = document.createElement('div');
div.className = 'user-card';
const nameSpan = document.createElement('span');
nameSpan.textContent = username; // Safe - textContent escapes
const emailLink = document.createElement('a');
emailLink.textContent = email;
emailLink.href = `mailto:${encodeURIComponent(email)}`;
div.appendChild(nameSpan);
div.appendChild(emailLink);
return div;
}
// ✅ SECURE - Template literals with proper escaping
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Content Security Policy header example
// Set this in your server configuration
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${generateNonce()}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
`;
Secrets Management
Never hardcode secrets. Use environment variables and dedicated secrets management solutions.
API keys, passwords, tokens, and certificates should never appear in source code. A single exposed secret can compromise your entire system.
Secrets Management Best Practices
# Secure secrets management
import os
from functools import lru_cache
from typing import Optional
import boto3
from botocore.exceptions import ClientError
import json
class SecretsManager:
"""
Centralized secrets management.
Supports environment variables and AWS Secrets Manager.
"""
def __init__(self, use_aws: bool = False, region: str = "us-east-1"):
self.use_aws = use_aws
if use_aws:
self.client = boto3.client('secretsmanager', region_name=region)
self._cache = {}
def get_secret(self, key: str, required: bool = True) -> Optional[str]:
"""
Get secret from environment or secrets manager.
Caches values to minimize API calls.
"""
# Check cache first
if key in self._cache:
return self._cache[key]
value = None
# Try environment variable first
value = os.environ.get(key)
# Fall back to AWS Secrets Manager
if value is None and self.use_aws:
value = self._get_aws_secret(key)
if value is None and required:
raise ValueError(f"Required secret '{key}' not found")
# Cache the value
if value:
self._cache[key] = value
return value
def _get_aws_secret(self, secret_name: str) -> Optional[str]:
"""Retrieve secret from AWS Secrets Manager."""
try:
response = self.client.get_secret_value(SecretId=secret_name)
if 'SecretString' in response:
secret = response['SecretString']
# Handle JSON secrets
try:
parsed = json.loads(secret)
return parsed.get('value', secret)
except json.JSONDecodeError:
return secret
return None
except ClientError as e:
if e.response['Error']['Code'] == 'ResourceNotFoundException':
return None
raise
def rotate_secret(self, key: str, new_value: str) -> bool:
"""Rotate a secret value."""
if self.use_aws:
try:
self.client.put_secret_value(
SecretId=key,
SecretString=new_value
)
# Clear cache
self._cache.pop(key, None)
return True
except ClientError:
return False
return False
# Usage example
secrets = SecretsManager(use_aws=True)
# Get database credentials
db_host = secrets.get_secret('DB_HOST')
db_password = secrets.get_secret('DB_PASSWORD')
api_key = secrets.get_secret('API_KEY')
Pre-commit Secret Detection
# .pre-commit-config.yaml
repos:
# Detect secrets before commit
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: package-lock.json
# Prevent private keys
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: detect-private-key
- id: check-added-large-files
args: ['--maxkb=500']
# Git-secrets for AWS credentials
- repo: https://github.com/awslabs/git-secrets
rev: master
hooks:
- id: git-secrets
Secure CI/CD Pipeline
Automate security checks throughout your CI/CD pipeline to catch vulnerabilities before deployment.
Complete Security Pipeline
# .github/workflows/security-pipeline.yml
name: Security Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
# Run weekly security scan
- cron: '0 0 * * 0'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Stage 1: Static Analysis
sast:
name: Static Application Security Testing
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: javascript, python
- name: Run CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Run Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/secrets
p/owasp-top-ten
# Stage 2: Dependency Scanning
sca:
name: Software Composition Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: Run OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: '${{ github.repository }}'
path: '.'
format: 'SARIF'
- name: Upload Dependency Check results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: reports/dependency-check-report.sarif
# Stage 3: Container Security
container-scan:
name: Container Security Scan
runs-on: ubuntu-latest
needs: [sast, sca]
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t ${{ env.IMAGE_NAME }}:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: '${{ env.IMAGE_NAME }}:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Run Grype scanner
uses: anchore/scan-action@v3
with:
image: '${{ env.IMAGE_NAME }}:${{ github.sha }}'
fail-build: true
severity-cutoff: high
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'
# Stage 4: Infrastructure as Code Scanning
iac-scan:
name: IaC Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: ./infrastructure
framework: terraform,kubernetes,dockerfile
output_format: sarif
- name: Run tfsec
uses: aquasecurity/[email protected]
with:
soft_fail: false
# Stage 5: Dynamic Testing (on staging)
dast:
name: Dynamic Application Security Testing
runs-on: ubuntu-latest
needs: [container-scan]
if: github.ref == 'refs/heads/main'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
# Deploy application to staging environment
kubectl apply -f k8s/staging/
- name: Wait for deployment
run: |
kubectl rollout status deployment/app -n staging
- name: Run OWASP ZAP scan
uses: zaproxy/[email protected]
with:
target: ${{ secrets.STAGING_URL }}
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a'
- name: Run Nuclei scanner
uses: projectdiscovery/nuclei-action@main
with:
target: ${{ secrets.STAGING_URL }}
templates: cves,vulnerabilities,misconfiguration
Container Security
Containers require specific security considerations. Follow these best practices for secure containerization.
Minimal Base Images
Use distroless or alpine images to reduce attack surface
Non-Root User
Never run containers as root user
Read-Only Filesystem
Mount filesystem as read-only when possible
Security Contexts
Drop capabilities, enable seccomp profiles
Secure Dockerfile
# Secure Dockerfile Best Practices
# Use specific version, not 'latest'
FROM python:3.12-slim-bookworm AS builder
# Set build-time arguments
ARG APP_VERSION=1.0.0
# Install build dependencies in builder stage
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Production stage - minimal image
FROM python:3.12-slim-bookworm AS production
# Security: Create non-root user
RUN groupadd --gid 1000 appgroup \
&& useradd --uid 1000 --gid appgroup --shell /bin/false appuser
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Set working directory
WORKDIR /app
# Copy application code with correct ownership
COPY --chown=appuser:appgroup . .
# Security: Remove unnecessary files
RUN find . -type f -name "*.pyc" -delete \
&& find . -type d -name "__pycache__" -delete \
&& rm -rf .git .env* tests/
# Security: Set restrictive permissions
RUN chmod -R 550 /app
# Environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONFAULTHANDLER=1 \
APP_VERSION=${APP_VERSION}
# Security: Switch to non-root user
USER appuser
# Expose port (documentation only)
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
# Use exec form for proper signal handling
ENTRYPOINT ["python"]
CMD ["-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Kubernetes Security Context
# k8s/deployment.yaml - Secure Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app
labels:
app: secure-app
spec:
replicas: 3
selector:
matchLabels:
app: secure-app
template:
metadata:
labels:
app: secure-app
spec:
# Security: Use non-root security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
# Security: Restrict service account
serviceAccountName: app-service-account
automountServiceAccountToken: false
containers:
- name: app
image: myapp:v1.0.0@sha256:abc123... # Use digest
imagePullPolicy: Always
# Security: Container-level security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
privileged: false
# Resource limits prevent DoS
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
# Mount secrets securely
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
# Writable directories if needed
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/.cache
# Probes for reliability
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir:
sizeLimit: 100Mi
# Security: Network policies
# Defined separately in NetworkPolicy resource
Security Monitoring
Continuous monitoring enables rapid detection and response to security incidents.
Vulnerability Count
Track open security issues by severity
Mean Time to Remediate
Measure how fast vulnerabilities are fixed
Security Test Pass Rate
Percentage of builds passing security gates
Incident Response Time
Time to detect and respond to threats
Security Metrics Dashboard
| Metric | Target | Alert Threshold |
|---|---|---|
| Critical Vulnerabilities | 0 | Any critical finding |
| High Vulnerabilities | < 5 | > 10 open |
| MTTR (Critical) | < 24 hours | > 48 hours |
| MTTR (High) | < 7 days | > 14 days |
| Security Test Pass Rate | > 95% | < 90% |
| Dependency Update Lag | < 30 days | > 60 days behind |
| Failed Login Attempts | < 1% | > 5% (credential stuffing) |
Security Checklists
- Threat model completed for new features
- Security requirements defined and documented
- Data classification performed
- Third-party components evaluated for security
- Architecture reviewed by security team
- All SAST findings addressed (no critical/high)
- Dependency vulnerabilities resolved
- Container images scanned and signed
- Secrets properly managed (no hardcoded values)
- Security tests pass in CI/CD pipeline
- Penetration testing completed
- Security monitoring and alerting configured
- Weekly dependency updates reviewed
- Monthly access reviews conducted
- Quarterly penetration testing
- Security training for new team members
- Incident response plan tested annually
- Security champions program active
Incident Response
Every team needs a clear incident response plan. When a security incident occurs, time is critical.
Detection & Triage
Identify the incident, assess severity, and classify the type (data breach, malware, unauthorized access, etc.).
Containment
Isolate affected systems, revoke compromised credentials, and prevent further damage. Preserve evidence for investigation.
Eradication
Remove the threat, patch vulnerabilities, and eliminate attacker access. Verify systems are clean.
Recovery
Restore systems to normal operation, monitor for recurrence, and validate security controls are effective.
Lessons Learned
Document the incident, conduct post-mortem, identify improvements, and update security controls and procedures.
Key Takeaways
- Shift left — Integrate security from design, not as an afterthought
- Automate everything — Manual security checks don't scale; automate in CI/CD
- Defense in depth — Layer multiple security controls; no single point of failure
- Least privilege — Grant minimum permissions necessary for each component
- Assume breach — Design systems to limit blast radius when (not if) a breach occurs
- Continuous improvement — Security is a journey, not a destination; keep learning
Next Steps
Ready to improve your security posture? Start with these high-impact actions:
Enable SAST/SCA
Add automated scanning to your CI/CD pipeline today
Secrets Audit
Scan repositories for hardcoded secrets and rotate them
Update Dependencies
Address known vulnerabilities in your dependencies
Security Training
Ensure all developers understand secure coding basics