Secure Coding Practices: Writing Code That Doesn’t Get Hacked

Security isn’t something you add later. It’s built into code from the first line.

This guide covers practical secure coding techniques that prevent the vulnerabilities attackers actually exploit.

The Security Mindset

"Never trust user input"
"Defense in depth"
"Fail securely"
"Principle of least privilege"

Every piece of data from outside your system boundary is potentially malicious:

  • Query parameters
  • Form data
  • HTTP headers
  • Cookies
  • File uploads
  • Database results (if shared with other systems)
  • API responses
  • Environment variables (in some contexts)

Input Validation

The #1 cause of vulnerabilities: trusting input.

The Wrong Way

# BAD: Direct use of user input
@app.route('/user/<user_id>')
def get_user(user_id):
    query = f"SELECT * FROM users WHERE id = {user_id}"
    return db.execute(query)
    # SQL Injection! 
    # user_id = "1; DROP TABLE users;--"

The Right Way

# GOOD: Parameterized query + type validation
@app.route('/user/<int:user_id>')  # Type constraint
def get_user(user_id: int):
    query = "SELECT * FROM users WHERE id = ?"
    return db.execute(query, (user_id,))  # Parameterized

Validation Principles

# 1. Whitelist, don't blacklist
# BAD: Block known bad
if "<script>" not in user_input:  # Easily bypassed
    process(user_input)

# GOOD: Allow known good
if re.match(r'^[a-zA-Z0-9_]{1,50}$', username):
    process(username)

# 2. Validate type, length, format, range
def validate_age(age: str) -> int:
    try:
        age_int = int(age)
        if not 0 <= age_int <= 150:
            raise ValueError("Age out of range")
        return age_int
    except ValueError:
        raise ValidationError("Invalid age")

# 3. Canonicalize before validation
# BAD: Validate then decode
if "../" not in path:
    file = open(url_decode(path))  # Bypass with %2e%2e%2f

# GOOD: Decode then validate
decoded_path = url_decode(path)
if "../" not in decoded_path:
    file = open(decoded_path)

SQL Injection Prevention

Rule: Never concatenate user input into SQL queries.

Every Language

# Python (sqlite3)
cursor.execute("SELECT * FROM users WHERE email = ?", (email,))

# Python (psycopg2)
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
// Node.js (pg)
await pool.query('SELECT * FROM users WHERE email = $1', [email]);

// Node.js (mysql2)
await connection.execute('SELECT * FROM users WHERE email = ?', [email]);
// Java (JDBC)
PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM users WHERE email = ?");
stmt.setString(1, email);
// Go (database/sql)
db.QueryRow("SELECT * FROM users WHERE email = $1", email)

ORMs Aren’t Magic

# Even with ORMs, raw queries can be dangerous
# BAD
User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")

# GOOD
User.objects.filter(name=name)

Cross-Site Scripting (XSS) Prevention

Rule: Encode output based on context.

Output Encoding

<!-- Context: HTML body -->
<p>Hello, {{ user.name | escape }}</p>
<!-- <script> becomes &lt;script&gt; -->

<!-- Context: HTML attribute -->
<input value="{{ user.name | attr_escape }}">
<!-- " becomes &quot; -->

<!-- Context: JavaScript -->
<script>
    var name = {{ user.name | json_encode }};
</script>
<!-- " becomes \" -->

<!-- Context: URL -->
<a href="/search?q={{ query | url_encode }}">
<!-- spaces become %20, etc. -->

Framework Auto-Escaping

# Django: Auto-escapes by default
{{ user.name }}  # Safe
{{ user.name | safe }}  # DANGEROUS - only use for trusted HTML

# React: Auto-escapes by default
<div>{user.name}</div>  // Safe
<div dangerouslySetInnerHTML={{__html: user.bio}} />  // DANGEROUS

Content Security Policy

# Add CSP header to prevent inline scripts
@app.after_request
def add_security_headers(response):
    response.headers['Content-Security-Policy'] = (
        "default-src 'self'; "
        "script-src 'self' 'nonce-{nonce}'; "  # Only allow nonced scripts
        "style-src 'self' 'unsafe-inline'; "
        "img-src 'self' data:; "
    )
    return response

Authentication Security

Password Storage

# NEVER store plaintext passwords
# NEVER use MD5/SHA1/SHA256 alone (too fast to crack)

# GOOD: Use bcrypt, scrypt, or Argon2
import bcrypt

def hash_password(password: str) -> str:
    salt = bcrypt.gensalt(rounds=12)
    return bcrypt.hashpw(password.encode(), salt).decode()

def verify_password(password: str, hash: str) -> bool:
    return bcrypt.checkpw(password.encode(), hash.encode())

Timing-Safe Comparison

# BAD: Early exit reveals password length
def check_password(input_pw, stored_pw):
    if len(input_pw) != len(stored_pw):
        return False
    for i in range(len(input_pw)):
        if input_pw[i] != stored_pw[i]:
            return False  # Timing attack possible
    return True

# GOOD: Constant-time comparison
import hmac

def check_password(input_pw: str, stored_hash: str) -> bool:
    return hmac.compare_digest(
        hash_password(input_pw),
        stored_hash
    )

Session Management

# Session token requirements:
# - At least 128 bits of entropy
# - Regenerate after login (prevent fixation)
# - Set Secure, HttpOnly, SameSite flags

from flask import session
import secrets

@app.route('/login', methods=['POST'])
def login():
    if authenticate(request.form['email'], request.form['password']):
        session.regenerate()  # Prevent session fixation
        session['user_id'] = user.id
        return redirect('/dashboard')
    
# Cookie settings
app.config['SESSION_COOKIE_SECURE'] = True    # HTTPS only
app.config['SESSION_COOKIE_HTTPONLY'] = True  # No JavaScript access
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF protection

Authorization

Check Permissions Everywhere

# BAD: Authorization only at UI level
@app.route('/admin/users/<user_id>/delete', methods=['POST'])
def delete_user(user_id):
    User.query.filter_by(id=user_id).delete()
    # Anyone who knows the URL can delete users!

# GOOD: Server-side authorization
@app.route('/admin/users/<user_id>/delete', methods=['POST'])
@login_required
def delete_user(user_id):
    if not current_user.has_permission('admin:delete_users'):
        abort(403)
    User.query.filter_by(id=user_id).delete()

Insecure Direct Object References (IDOR)

# BAD: User controls object ID without verification
@app.route('/documents/<doc_id>')
def get_document(doc_id):
    return Document.query.get(doc_id)
    # User can access any document by changing doc_id!

# GOOD: Verify ownership
@app.route('/documents/<doc_id>')
@login_required
def get_document(doc_id):
    doc = Document.query.get_or_404(doc_id)
    if doc.owner_id != current_user.id:
        abort(403)
    return doc

Cryptography

Use Libraries, Not DIY

# BAD: Rolling your own crypto
def encrypt(data, key):
    return bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])

# GOOD: Use established libraries
from cryptography.fernet import Fernet

key = Fernet.generate_key()  # Store securely!
cipher = Fernet(key)
encrypted = cipher.encrypt(data.encode())
decrypted = cipher.decrypt(encrypted).decode()

Secrets Management

# BAD: Hardcoded secrets
API_KEY = "sk_live_abc123"
DB_PASSWORD = "supersecret"

# GOOD: Environment variables or secrets manager
import os
API_KEY = os.environ['API_KEY']

# BETTER: Dedicated secrets manager
from aws_secretsmanager import get_secret
API_KEY = get_secret('production/api_key')

Random Numbers

# BAD: Predictable randomness
import random
token = ''.join(random.choices(string.ascii_letters, k=32))
# random.choices is NOT cryptographically secure!

# GOOD: Cryptographic randomness
import secrets
token = secrets.token_urlsafe(32)  # 256 bits of entropy

Error Handling

Don’t Leak Information

# BAD: Detailed errors to users
@app.errorhandler(Exception)
def handle_error(e):
    return jsonify({
        'error': str(e),
        'traceback': traceback.format_exc(),  # Exposes internals!
        'database_query': str(e.statement)     # Exposes SQL!
    }), 500

# GOOD: Generic errors to users, detailed logs internally
@app.errorhandler(Exception)
def handle_error(e):
    error_id = str(uuid.uuid4())
    app.logger.error(f"Error {error_id}: {e}", exc_info=True)
    
    return jsonify({
        'error': 'An internal error occurred',
        'error_id': error_id  # For support reference
    }), 500

Fail Securely

# BAD: Default to allow
def check_permission(user, resource):
    try:
        return permission_service.check(user, resource)
    except ServiceError:
        return True  # Default allow on error!

# GOOD: Default to deny
def check_permission(user, resource):
    try:
        return permission_service.check(user, resource)
    except ServiceError:
        logger.error("Permission check failed, denying access")
        return False  # Default deny on error

Dependency Security

# Regularly audit dependencies
npm audit
pip-audit
bundle audit

# Pin versions in production
# requirements.txt
requests==2.28.1
django==4.2.7

# Use lockfiles
package-lock.json
poetry.lock
Pipfile.lock

# Automate updates
# Dependabot, Renovate, Snyk

Security Checklist

## Input Handling
□ All user input validated (type, length, format)
□ Whitelist validation preferred
□ Canonicalization before validation

## Database
□ Parameterized queries everywhere
□ ORM raw queries reviewed
□ Database user has minimal permissions

## Output
□ Context-appropriate encoding
□ CSP headers implemented
□ X-Content-Type-Options: nosniff

## Authentication
□ Passwords hashed with bcrypt/Argon2
□ Sessions regenerated after login
□ Secure cookie flags set
□ Rate limiting on login

## Authorization
□ Server-side permission checks
□ IDOR protections
□ Principle of least privilege

## Cryptography
□ Using established libraries
□ No hardcoded secrets
□ Cryptographic random for tokens

## Error Handling
□ No sensitive info in error messages
□ Fail securely (deny by default)
□ Detailed logging for debugging

## Dependencies
□ Regular security audits
□ Pinned versions
□ Automated update monitoring

Conclusion

Secure coding isn’t about memorizing every attack. It’s about habits:

  1. Validate everything — All input is suspect
  2. Encode output — Context matters
  3. Use frameworks — They handle common vulnerabilities
  4. Check authorization — On every request
  5. Fail securely — Deny by default
  6. Log appropriately — Details for debugging, not for attackers

Security debt is the most expensive kind. Build it right from the start.


Related: DevSecOps Integration