Las credenciales hardcodeadas son una de las vulnerabilidades más comunes y costosas en producción. No es exageración: GitHub tiene bots que escanean commits en tiempo real buscando patrones de API keys, contraseñas y tokens. Si pushas API_KEY = "sk-abc123..." aunque sea por un segundo y luego lo eliminas en el siguiente commit, el secreto ya está comprometido — la historia de git lo conserva para siempre, y cualquier fork o clone hecho durante esa ventana ya lo tiene.
El mecanismo de defensa tiene tres capas: dónde viven los secretos (fuera del código), cómo los generás (con entropía criptográfica real), y cómo los comparás (sin filtrar información por tiempo de ejecución).
Variables de entorno: el canal estándar
El contrato es simple: el sistema operativo o la plataforma de despliegue inyecta valores sensibles como variables de entorno, y tu código los lee con os.environ. La función os.environ.get() es preferible a os.environ[] porque te permite especificar un valor por defecto explícito que deja claro que algo falta:
import os
db_password = os.environ.get("DB_PASSWORD")
if db_password is None:
raise RuntimeError("DB_PASSWORD no está configurada — no arranca sin ella")
Usar None como señal de fallo y fallar rápido es mucho mejor que dejar que la app arranque con una contraseña vacía o con un fallback que parece funcionar pero no debería.
python-dotenv en desarrollo local
En producción las variables de entorno las gestiona tu plataforma (Railway, Heroku, Kubernetes secrets, etc.). En local necesitás algo equivalente sin hardcodear nada en el código. Ahí entra python-dotenv.
# .env ← NUNCA en git, siempre en .gitignore DB_PASSWORD=supersecreta_local SECRET_KEY=dev-key-solo-local API_TOKEN=tok_test_abc123
# config.py
import os
from dotenv import load_dotenv
# Carga .env en os.environ antes de que cualquier otro módulo lo lea.
# En producción, si las vars ya existen, load_dotenv() no las pisa (override=False por defecto).
load_dotenv()
DB_PASSWORD = os.environ.get("DB_PASSWORD")
SECRET_KEY = os.environ.get("SECRET_KEY")
API_TOKEN = os.environ.get("API_TOKEN")
if not all([DB_PASSWORD, SECRET_KEY, API_TOKEN]):
missing = [k for k, v in {
"DB_PASSWORD": DB_PASSWORD,
"SECRET_KEY": SECRET_KEY,
"API_TOKEN": API_TOKEN,
}.items() if not v]
raise RuntimeError(f"Variables de entorno faltantes: {missing}")
El .gitignore debe tener .env desde el primer commit del proyecto. Lo que sí podés commitear es un .env.example con las claves pero sin valores reales, que sirve de documentación:
# .env.example ← sí va en git DB_PASSWORD= SECRET_KEY= API_TOKEN=
El módulo secrets: entropía criptográfica real
Cuando necesitás generar tokens, claves de sesión, códigos de verificación o cualquier valor que deba ser impredecible, el módulo secrets de la stdlib es lo que usás. Internamente usa el generador de números aleatorios del sistema operativo (os.urandom), que acumula entropía real de fuentes de hardware.
import secrets
import hashlib
import hmac
# Token hexadecimal: 32 bytes = 64 caracteres hex.
# Ideal para claves de API, tokens de reset de contraseña.
api_key = secrets.token_hex(32)
print(api_key) # "a3f9b2c1..." — 64 chars
# Token URL-safe: base64url sin padding.
# Ideal para links de confirmación en emails, session tokens.
session_token = secrets.token_urlsafe(16)
print(session_token) # "X7kP9mNq..." — ~22 chars
# Comparación en tiempo constante: CRÍTICO para tokens de autenticación.
def verify_token(submitted_token: str, stored_token: str) -> bool:
# secrets.compare_digest evita timing attacks al garantizar
# que la comparación siempre tarda lo mismo sin importar
# cuántos caracteres coinciden antes del primer fallo.
return secrets.compare_digest(
submitted_token.encode(),
stored_token.encode()
)
# Ejemplo de uso en un endpoint imaginario
user_provided = "X7kP9mNq..."
is_valid = verify_token(user_provided, session_token)
Por qué random no alcanza
random.random() implementa Mersenne Twister, un PRNG (Pseudo Random Number Generator) diseñado para simulaciones y juegos, no para seguridad. Con suficientes outputs observados (624 valores de 32 bits), su estado interno completo puede reconstruirse matemáticamente, lo que permite predecir todos los valores futuros y pasados. Si usás random para generar un token de reset de contraseña, un atacante con acceso a suficientes tokens previos podría predecir el siguiente.
import random
import secrets
# ❌ Predecible con suficiente análisis del estado interno
bad_token = ''.join(random.choices('abcdef0123456789', k=32))
# ✅ Criptográficamente seguro, basado en entropía del OS
good_token = secrets.token_hex(16)
Timing attacks y compare_digest
Un timing attack explota el hecho de que == en Python (y en casi todos los lenguajes) cortocircuita: falla en el primer byte que no coincide. Esto significa que "abc" == "xyz" tarda menos que "abc" == "abd". Midiendo suficientes comparaciones con microsegundos de precisión, un atacante puede ir descubriendo el token correcto byte a byte.
secrets.compare_digest() garantiza que la comparación siempre recorre toda la longitud del string, haciendo que el tiempo sea constante independientemente de cuántos bytes coincidan. Usalo siempre que compares tokens, HMACs o cualquier valor secreto recibido del exterior.
Errores que debes conocer
Error: Asumir que eliminar un secreto en un commit posterior lo borra de la historia.
# ❌ Wrong — commit A: esto queda en git PARA SIEMPRE
DB_PASSWORD = "production_secret_123"
# ❌ Wrong — commit B: el secreto sigue en git history
DB_PASSWORD = os.environ.get("DB_PASSWORD")
# ✅ Right — desde el primer commit, nunca hardcodeado
DB_PASSWORD = os.environ.get("DB_PASSWORD")
Una vez que un secreto toca git history, la única solución real es rotarlo (generar uno nuevo) en el sistema que lo usa. git filter-branch o git-filter-repo pueden reescribir la historia, pero si el repo fue clonado o forkeado, el secreto ya está afuera.
Error: Usar == para comparar tokens de autenticación recibidos del usuario.
# ❌ Wrong — vulnerable a timing attack
def check_token(user_token):
return user_token == stored_token
# ✅ Right
def check_token(user_token):
return secrets.compare_digest(user_token.encode(), stored_token.encode())
compare_digest requiere que ambos argumentos sean del mismo tipo (bytes o str), de ahí el .encode() explícito.
Error: Commitear .env por olvidar agregarlo a .gitignore antes del primer git add.
# ❌ Wrong — .env viaja al repo git add . git commit -m "initial commit" # ✅ Right — .gitignore primero, siempre echo ".env" >> .gitignore git add .gitignore .env.example git commit -m "initial commit"
Agregá .env a .gitignore antes de crear el archivo .env. Si ya está trackeado, git rm --cached .env lo saca del índice sin borrarlo del disco.
N° 198