Firma de Requests con HMAC
Asegura requests de APIs con firmas HMAC-SHA256 para garantizar integridad y autenticidad.
Visión General
HMAC (Hash-based Message Authentication Code) es el estándar de la industria para firmar requests de APIs. Al combinar un secreto compartido con el payload del request y un hash criptográfico, tanto emisor como receptor pueden verificar la integridad y autenticidad del mensaje sin transmitir el secreto por la red.
Cuándo Usar
Usa este recurso cuando:
- Autenticas llamadas API de servicio a servicio
- Aseguras que payloads de webhooks no han sido alterados
- Implementas autenticación de API keys sin la complejidad de OAuth
- Verificas integridad de requests a través de redes no confiables
Solución
Firma HMAC-SHA256 (Node.js)
const crypto = require('crypto');
function signRequest(method, path, body, timestamp, secret) {
const payload = method.toUpperCase() + path + timestamp + JSON.stringify(body);
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
function verifyRequest(method, path, body, timestamp, signature, secret) {
const expected = signRequest(method, path, body, timestamp, secret);
// Comparación en tiempo constante
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}
Ejemplo Cliente-Servidor (Python)
import hmac
import hashlib
import time
def sign_request(method: str, path: str, body: bytes, secret: str) -> str:
timestamp = str(int(time.time()))
message = f"{method.upper()}{path}{timestamp}{body.decode()}"
signature = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
return signature, timestamp
# Cliente
signature, ts = sign_request("POST", "/api/orders", b'{"id":1}', "my-secret")
headers = {"X-Signature": signature, "X-Timestamp": ts}
# Servidor
def verify(signature: str, timestamp: str, method, path, body, secret):
# Rechazar requests antiguos (protección contra replay)
if abs(int(time.time()) - int(timestamp)) > 300:
return False
expected, _ = sign_request(method, path, body, secret)
return hmac.compare_digest(signature, expected)
Explicación
La seguridad de HMAC se basa en tres propiedades:
- Clave secreta: Nunca transmitida; compartida out-of-band durante el onboarding
- Cobertura del mensaje: La firma debe cubrir method, path, timestamp y body
- Protección contra replay: Ventanas de tiempo previenen que atacantes reutilicen requests antiguos
¿Por qué no SHA-256 plano? SHA-256 sin HMAC es vulnerable a ataques de extensión de longitud. HMAC usa dos pasadas anidadas de hashing que previenen esto.
Variantes
| Algoritmo | Hash | Fortaleza | Notas |
|---|---|---|---|
| HMAC-SHA256 | SHA-256 | 128-bit | Recomendado por defecto |
| HMAC-SHA384 | SHA-384 | 192-bit | Mayor margen de seguridad |
| HMAC-SHA512 | SHA-512 | 256-bit | Más lento; usar en contextos de alta seguridad |
| HMAC-Blake3 | Blake3 | 256-bit | Rápido; alternativa moderna |
Lo que funciona
- Incluye timestamp: Rechaza requests más antiguos que 5 minutos para prevenir ataques de replay
- Firma todo el request: Method + path + timestamp + body (headers ordenados si se incluyen)
- Usa comparación en tiempo constante: timingSafeEqual previene ataques de timing
- Rota secretos regularmente: Usa versionado de claves (v1, v2) en el header de firma
- Nunca loguees el secreto: Loguea firmas y claves, nunca el secreto raw
Errores Comunes
- Firmar solo el body: Un atacante puede replayar un body válido con un endpoint diferente
- Faltar protección contra replay: Sin timestamps, requests interceptados son válidos para siempre
- Usar MD5 o SHA-1: Criptográficamente rotos; usar mínimo SHA-256
- Comparación de strings en lugar de timingSafeEqual: Vulnerable a ataques de timing
- Almacenar secretos en variables de entorno sin encriptar: Usa un secret manager
Soluciones Avanzadas
Firma HMAC-SHA256 en Java con rotación de claves
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class HmacSigner {
private final Map<String, String> secrets = new HashMap<>();
public HmacSigner() {
secrets.put("v1", "old-secret-key");
secrets.put("v2", "current-secret-key");
}
public String sign(String method, String path, String body, String timestamp, String keyVersion)
throws Exception {
String payload = method.toUpperCase() + path + timestamp + body;
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
secrets.get(keyVersion).getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(keySpec);
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return keyVersion + ":" + Base64.getEncoder().encodeToString(hash);
}
public boolean verify(String method, String path, String body, String timestamp, String signature)
throws Exception {
// Parsear versión de clave desde la firma: "v2:base64hash"
String[] parts = signature.split(":", 2);
if (parts.length != 2) return false;
String keyVersion = parts[0];
String receivedHash = parts[1];
if (!secrets.containsKey(keyVersion)) return false;
String expected = sign(method, path, body, timestamp, keyVersion);
String expectedHash = expected.split(":", 2)[1];
// Comparación en tiempo constante
return MessageDigest.isEqual(
receivedHash.getBytes(StandardCharsets.UTF_8),
expectedHash.getBytes(StandardCharsets.UTF_8)
);
}
}
// Uso
HmacSigner signer = new HmacSigner();
String sig = signer.sign("POST", "/api/orders", "{\"id\":1}", "1690000000", "v2");
// Headers: X-Signature: v2:base64hash, X-Timestamp: 1690000000
Verificación de firma de webhook con raw body
Muchas APIs (Stripe, GitHub, Slack) firman webhooks con HMAC. Debes verificar usando el raw body del request antes de cualquier parseo JSON:
const crypto = require('crypto');
/**
* Verifica una firma de webhook estilo Stripe.
* Stripe usa: t=<timestamp>,v1=<firma>
*/
function verifyWebhook(rawBody, signatureHeader, secret) {
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1];
const signatures = parts
.filter(p => p.startsWith('v1='))
.map(p => p.split('=')[1]);
if (!timestamp || signatures.length === 0) return false;
// Rechazar timestamps antiguos (ventana de 5 minutos)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300 || age < -300) return false;
// Calcular firma esperada: HMAC-SHA256(timestamp + rawBody)
const payload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Verificar contra todas las firmas proporcionadas (Stripe puede enviar múltiples)
return signatures.some(sig =>
crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex')
)
);
}
// Middleware Express: debe usar raw body
const express = require('express');
const app = express();
// IMPORTANTE: usar raw body para verificación de firma
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks/stripe', (req, res) => {
const rawBody = req.body.toString('utf8');
const sig = req.headers['stripe-signature'];
if (!verifyWebhook(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET)) {
return res.status(400).send('Firma inválida');
}
const event = JSON.parse(rawBody);
console.log('Webhook verificado:', event.type);
res.status(200).send('OK');
});
Prevención de replay con nonce
Los timestamps solos permiten una ventana de replay de 5 minutos. Añade un cache de nonce para rechazar requests duplicados dentro de esa ventana:
import hmac
import hashlib
import time
from collections import OrderedDict
from typing import Optional
class NonceCache:
"""Cache LRU para rastrear nonces usados dentro de la ventana de timestamp."""
def __init__(self, max_size: int = 10000, ttl_seconds: int = 300):
self._cache: OrderedDict[str, float] = OrderedDict()
self._max_size = max_size
self._ttl = ttl_seconds
def check_and_add(self, nonce: str) -> bool:
"""Retorna True si el nonce es nuevo (aceptable), False si es duplicado."""
now = time.time()
self._evict_expired(now)
if nonce in self._cache:
return False # Nonce duplicado
self._cache[nonce] = now
if len(self._cache) > self._max_size:
self._cache.popitem(last=False)
return True
def _evict_expired(self, now: float):
expired = [
k for k, ts in self._cache.items()
if now - ts > self._ttl
]
for k in expired:
self._cache.pop(k, None)
class HmacVerifier:
"""Verificador HMAC con prevención de replay basada en nonce."""
def __init__(self, secret: str, max_skew_seconds: int = 300):
self.secret = secret.encode()
self.max_skew = max_skew_seconds
self.nonces = NonceCache(max_size=10000, ttl_seconds=max_skew_seconds)
def verify(
self,
method: str,
path: str,
body: str,
timestamp: str,
nonce: str,
signature: str,
) -> bool:
# Verificar ventana de timestamp
try:
ts = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - ts) > self.max_skew:
return False
# Verificar unicidad del nonce
if not self.nonces.check_and_add(nonce):
return False
# Verificar firma
message = f"{method.upper()}{path}{timestamp}{nonce}{body}"
expected = hmac.new(
self.secret,
message.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Uso en el servidor
verifier = HmacVerifier("my-secret-key")
# El request llega con headers:
# X-Timestamp: 1690000000
# X-Nonce: a1b2c3d4-e5f6-7890-abcd-ef1234567890
# X-Signature: hexhash
is_valid = verifier.verify(
method="POST",
path="/api/orders",
body='{"id":1,"item":"widget"}',
timestamp="1690000000",
nonce="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
signature="abc123...",
)
Estrategia de rotación de claves
import hmac
import hashlib
import time
from typing import Optional
class KeyRotator:
"""Gestiona rotación de claves HMAC con período de superposición para zero downtime."""
def __init__(self):
self.keys: dict[str, dict] = {}
self.active_version: Optional[str] = None
def add_key(self, version: str, secret: str, activate: bool = True):
self.keys[version] = {
"secret": secret,
"created": time.time(),
}
if activate:
self.active_version = version
def deactivate_key(self, version: str):
self.keys.pop(version, None)
if self.active_version == version:
# Activar la clave más reciente restante
if self.keys:
self.active_version = max(self.keys.keys())
else:
self.active_version = None
def get_signing_key(self) -> tuple[str, str]:
"""Retorna (versión, secreto) para firmar nuevos requests."""
if not self.active_version:
raise RuntimeError("No hay clave de firma activa")
return self.active_version, self.keys[self.active_version]["secret"]
def get_verification_keys(self) -> list[tuple[str, str]]:
"""Retorna todas las claves válidas para verificar requests entrantes."""
return [(v, info["secret"]) for v, info in self.keys.items()]
def sign(self, method: str, path: str, body: str, timestamp: str) -> str:
version, secret = self.get_signing_key()
message = f"{method.upper()}{path}{timestamp}{body}"
sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
return f"{version}:{sig}"
def verify(self, method: str, path: str, body: str, timestamp: str, signature: str) -> bool:
parts = signature.split(":", 1)
if len(parts) != 2:
return False
version, received_sig = parts
for v, secret in self.get_verification_keys():
if v != version:
continue
message = f"{method.upper()}{path}{timestamp}{body}"
expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
if hmac.compare_digest(received_sig, expected):
return True
return False
# Flujo de rotación:
# 1. Añadir nueva clave (v2) — tanto v1 como v2 son válidas para verificación
rotator = KeyRotator()
rotator.add_key("v1", "old-secret", activate=False)
rotator.add_key("v2", "new-secret", activate=True)
# 2. Desplegar: nuevos requests firmados con v2, requests v1 antiguos aún verifican
# 3. Después de que todos los requests antiguos expiren (ventana de timestamp), desactivar v1
# rotator.deactivate_key("v1") Preguntas frecuentes
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
Recursos Relacionados
Checklist de Seguridad API: Autenticación a Encriptación
Checklist de seguridad esencial para APIs: autenticación, autorización, validación de entrada, rate limiting, encriptación, logging y endurecimiento de despliegue.
GuideGuía de Mejores Prácticas de Seguridad
Una Referencia Detallada de seguridad de aplicaciones: autenticación, autorización, validación de inputs, gestión de secretos y prevención de vulnerabilidades comunes.
GuideSeguridad de Aplicaciones Web (OWASP Top 10)
Una guía enfocada en desarrolladores sobre el OWASP Top 10: inyección, control de acceso roto, XSS, diseño inseguro, y cómo prevenir cada vulnerabilidad con ejemplos de código.
RecipeAutenticacion y Patrones de Seguridad para WebSockets
Como autenticar conexiones WebSocket, implementar validacion de tokens y manejar autorizacion para mensajeria en tiempo real en produccion
RecipeProteger Formularios Web Contra Ataques CSRF
Cómo prevenir ataques de Cross-Site Request Forgery usando tokens de sincronización, cookies SameSite y patrones de double-submit cookie.
RecipeHashing de Contraseñas en Producción
Hashea y verifica contraseñas de forma segura usando bcrypt, scrypt y Argon2 con lo que funciona.