Building a Secure Python App: End-to-End Crypto Guide
Building a genuinely secure application requires integrating cryptographic techniques at every layer: user authentication (password hashing), data in transit (TLS), data at rest (symmetric encryption), and message integrity (digital signatures). This article walks through designing and implementing a complete secure Python application that handles sensitive user data correctly.
The example is a simple note-sharing application where users upload encrypted notes that only the note owner can decrypt. The application demonstrates password hashing, symmetric encryption, TLS, secure key management, and secure session handling—principles applicable to any Python web application.
Secure Application Architecture
A secure architecture separates concerns and encrypts data at each layer:
User Input (HTTPS/TLS)
↓
Authentication Layer (bcrypt password hashing)
↓
Session Management (signed, encrypted tokens)
↓
Application Logic Layer (authorization checks)
↓
Database Layer (encrypted at rest)
↓
Key Management (secrets manager)
Each layer has a specific responsibility; none trusts the layers below it.
Complete Example: Secure Notes Application
Here's a Flask application that implements end-to-end encryption and secure password handling:
# app.py - Secure notes application
from flask import Flask, request, jsonify, session
from flask_cors import CORS
from functools import wraps
import bcrypt
import json
import hmac
import hashlib
from datetime import datetime, timedelta
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import os
import jwt
app = Flask(__name__)
CORS(app)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-key-only")
# In production, load from secrets manager
def get_secret(name):
"""Load a secret from environment or secrets manager."""
value = os.getenv(name)
if not value:
raise ValueError(f"Secret {name} not found in environment")
return value
# Initialize secrets (only on first run; thereafter, load from secrets manager)
MASTER_ENCRYPTION_KEY = get_secret("MASTER_ENCRYPTION_KEY").encode()
JWT_SECRET = get_secret("JWT_SECRET")
# Database mock (in production, use a real database)
users_db = {} # {username: {password_hash, created_at}}
notes_db = {} # {username: [{note_id, iv, ciphertext, tag, created_at}]}
# ==================== USER MANAGEMENT ====================
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
def verify_password(plaintext: str, hashed: str) -> bool:
"""Verify a plaintext password against a bcrypt hash."""
try:
return bcrypt.checkpw(plaintext.encode(), hashed.encode())
except Exception:
return False
@app.route("/api/auth/register", methods=["POST"])
def register():
"""Register a new user."""
data = request.get_json() or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
# Validate input
if not username or len(username) < 3:
return jsonify({"error": "Username must be at least 3 characters"}), 400
if not password or len(password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
if username in users_db:
return jsonify({"error": "Username already exists"}), 409
# Store user with hashed password (never plaintext)
users_db[username] = {
"password_hash": hash_password(password),
"created_at": datetime.utcnow().isoformat()
}
return jsonify({"message": "User registered successfully"}), 201
@app.route("/api/auth/login", methods=["POST"])
def login():
"""Authenticate a user and return a JWT token."""
data = request.get_json() or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
if not username or not password:
return jsonify({"error": "Missing credentials"}), 400
user = users_db.get(username)
if not user or not verify_password(password, user["password_hash"]):
return jsonify({"error": "Invalid credentials"}), 401
# Issue a JWT token (signed with server's key)
token = jwt.encode(
{
"username": username,
"exp": datetime.utcnow() + timedelta(hours=24),
"iat": datetime.utcnow()
},
JWT_SECRET,
algorithm="HS256"
)
return jsonify({"token": token}), 200
def require_auth(f):
"""Decorator: require a valid JWT token."""
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token:
return jsonify({"error": "Missing token"}), 401
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
request.user = payload["username"]
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
return f(*args, **kwargs)
return decorated
# ==================== NOTE ENCRYPTION ====================
def encrypt_note(plaintext: str, master_key: bytes) -> dict:
"""
Encrypt a note using AES-256-GCM.
Returns:
dict: {"iv": hex, "ciphertext": hex, "tag": hex}
"""
iv = os.urandom(12)
cipher = Cipher(
algorithms.AES(master_key),
modes.GCM(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext.encode()) + encryptor.finalize()
tag = encryptor.tag
return {
"iv": iv.hex(),
"ciphertext": ciphertext.hex(),
"tag": tag.hex()
}
def decrypt_note(encrypted: dict, master_key: bytes) -> str:
"""
Decrypt a note using AES-256-GCM.
Args:
encrypted: dict with "iv", "ciphertext", "tag" (hex-encoded)
master_key: bytes, the master encryption key
Returns:
str: Decrypted plaintext
"""
iv = bytes.fromhex(encrypted["iv"])
ciphertext = bytes.fromhex(encrypted["ciphertext"])
tag = bytes.fromhex(encrypted["tag"])
cipher = Cipher(
algorithms.AES(master_key),
modes.GCM(iv, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return plaintext.decode()
# ==================== NOTE ENDPOINTS ====================
@app.route("/api/notes", methods=["POST"])
@require_auth
def create_note():
"""Create and encrypt a new note."""
data = request.get_json() or {}
plaintext = data.get("content", "").strip()
if not plaintext or len(plaintext) > 100000:
return jsonify({"error": "Invalid note content"}), 400
# Encrypt the note
encrypted = encrypt_note(plaintext, MASTER_ENCRYPTION_KEY)
# Store encrypted note
username = request.user
if username not in notes_db:
notes_db[username] = []
note_id = hashlib.sha256(
(username + str(datetime.utcnow())).encode()
).hexdigest()[:16]
notes_db[username].append({
"id": note_id,
"iv": encrypted["iv"],
"ciphertext": encrypted["ciphertext"],
"tag": encrypted["tag"],
"created_at": datetime.utcnow().isoformat()
})
return jsonify({"id": note_id, "created_at": datetime.utcnow().isoformat()}), 201
@app.route("/api/notes/<note_id>", methods=["GET"])
@require_auth
def get_note(note_id):
"""Retrieve and decrypt a note (only owner can access)."""
username = request.user
if username not in notes_db:
return jsonify({"error": "Note not found"}), 404
# Find the note
note = None
for n in notes_db[username]:
if n["id"] == note_id:
note = n
break
if not note:
return jsonify({"error": "Note not found"}), 404
# Decrypt the note
try:
plaintext = decrypt_note(
{
"iv": note["iv"],
"ciphertext": note["ciphertext"],
"tag": note["tag"]
},
MASTER_ENCRYPTION_KEY
)
except Exception as e:
return jsonify({"error": "Failed to decrypt note"}), 500
return jsonify({
"id": note["id"],
"content": plaintext,
"created_at": note["created_at"]
}), 200
@app.route("/api/notes", methods=["GET"])
@require_auth
def list_notes():
"""List all note IDs for the current user (no decryption)."""
username = request.user
if username not in notes_db:
return jsonify({"notes": []}), 200
notes = [
{"id": n["id"], "created_at": n["created_at"]}
for n in notes_db[username]
]
return jsonify({"notes": notes}), 200
@app.route("/api/notes/<note_id>", methods=["DELETE"])
@require_auth
def delete_note(note_id):
"""Delete a note (owner only)."""
username = request.user
if username not in notes_db:
return jsonify({"error": "Note not found"}), 404
notes_db[username] = [n for n in notes_db[username] if n["id"] != note_id]
return jsonify({"message": "Note deleted"}), 200
# ==================== SECURITY HEADERS ====================
@app.after_request
def add_security_headers(response):
"""Add security headers to every response."""
# Enforce HTTPS
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
# Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY"
# Disable MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Enable XSS protection
response.headers["X-XSS-Protection"] = "1; mode=block"
# Content Security Policy
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'"
return response
# ==================== ERROR HANDLING ====================
@app.errorhandler(404)
def not_found(error):
return jsonify({"error": "Not found"}), 404
@app.errorhandler(500)
def internal_error(error):
app.logger.error(f"Unhandled error: {error}")
return jsonify({"error": "Internal server error"}), 500
if __name__ == "__main__":
# In production, load secrets first
# Run with: python app.py
# Requires environment variables: SECRET_KEY, MASTER_ENCRYPTION_KEY, JWT_SECRET
# NEVER run with debug=True in production
app.run(ssl_context="adhoc", debug=False, host="0.0.0.0", port=443)
Key Security Features
1. Password Hashing (bcrypt): Passwords are hashed with bcrypt (cost factor 12), never stored in plaintext. Verification happens via hash comparison.
2. End-to-End Encryption (AES-256-GCM): Notes are encrypted using AES-256-GCM with a master key. Only the server knows the key; clients receive encrypted data.
3. JWT Authentication: After login, clients receive a signed JWT token. The token is verified on each request, and the username is extracted from the payload.
4. HTTPS/TLS: All communication is over HTTPS (run with ssl_context="adhoc"). In production, use certificates from a CA.
5. Security Headers: Every response includes headers that prevent clickjacking, MIME sniffing, XSS, and enforce HTTPS.
6. Input Validation: All user input is validated (length, format) before use.
7. Authorization: Only the note owner can decrypt or delete their notes.
Testing the Application
# Install dependencies
pip install flask flask-cors bcrypt cryptography pyjwt
# Set environment variables (development)
export SECRET_KEY="dev-secret-key"
export MASTER_ENCRYPTION_KEY="zH84jk1Yy3t5vW8xZ2qP4mN6bL9dF1jK"
export JWT_SECRET="jwt-secret-key"
# Run the server
python app.py
Client example using curl:
# Register
curl -X POST http://localhost:443/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "SecureP@ss123"}'
# Login
TOKEN=$(curl -X POST http://localhost:443/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "SecureP@ss123"}' | jq -r '.token')
# Create a note
curl -X POST http://localhost:443/api/notes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "My secret note"}'
# List notes
curl http://localhost:443/api/notes \
-H "Authorization: Bearer $TOKEN"
Production Checklist
Deploying this application to production requires additional steps:
- Load secrets from AWS Secrets Manager (or similar) instead of environment variables.
- Set up HTTPS with certificates from a trusted CA (Let's Encrypt).
- Configure database: replace in-memory dictionaries with a real database (PostgreSQL, MongoDB).
- Implement key rotation: generate new master keys periodically.
- Add audit logging: log all authentication attempts, data access.
- Set up monitoring and alerting: alert on failed login attempts, errors.
- Implement rate limiting: prevent brute-force password attacks.
- Enable CORS properly: specify allowed origins instead of allowing all.
- Use a production WSGI server (gunicorn, uWSGI) instead of Flask's development server.
- Set up backups: encrypt and back up the database regularly.
- Perform security testing: penetration testing, vulnerability scanning.
Key Takeaways
- Building secure applications requires cryptography at every layer: authentication, data in transit, data at rest, and session management.
- Password hashing (bcrypt) is the foundation of user security; encryption (AES) protects sensitive data.
- TLS/HTTPS encrypts data in transit; symmetric encryption protects data at rest. Both are necessary.
- Key management is critical: load keys from secrets managers, never hardcode them.
- Input validation, authorization checks, and security headers are non-cryptographic but equally important for security.
- Test extensively; security is not an afterthought but a design principle throughout the application.
Frequently Asked Questions
Should I encrypt all database columns?
Only encrypt columns containing sensitive data (passwords already hashed, API keys, PII). Full-database encryption adds overhead and can slow queries. Encrypt strategically.
Can I use the same key for all encrypted fields?
Yes, from a cryptographic perspective. However, best practice is to use per-column or per-table keys if feasible, limiting the exposure of a single key compromise.
What if the master key is compromised?
All encrypted data is exposed. Implement key rotation: generate a new key, re-encrypt all data with the new key in the background, retire the old key. Have a plan before compromise occurs.
How do I handle database backups?
Encrypt backups with a separate backup key (or use database-level encryption). Store backup keys in a separate location from the production database.
Should I implement rate limiting?
Absolutely. Add rate limiting to login attempts (e.g., 5 failed attempts per minute per IP) to prevent brute-force attacks. Use a library like Flask-Limiter.