Firma HMAC para autenticación de API (AWS Sig v4)
Asegurá peticiones de API con firmas HMAC y autenticación AWS Signature v4 para integridad de mensajes a prueba de manipulaciones.
Visión general
TLS protege los bytes en tránsito, pero no puede probar que el cliente que los envía sea uno de confianza — ni que una petición capturada no se haya repetido una hora después. La firma con HMAC-SHA256 cierra esa brecha: cliente y servidor comparten un secreto, el cliente firma una forma canónica de la petición y el servidor recalcula la firma al recibirla. Un byte distinto y la petición muere antes de tocar tu handler.
Las recetas de abajo construyen eso en Python, JavaScript, Java y Go, y después van más lejos: una implementación completa de AWS Signature v4, un verificador de webhooks con protección contra replay por nonce y un esquema de rotación de claves que no deja clientes fuera a mitad de camino.
Cuándo usar
Usá firma HMAC cuando:
- Entregás o consumís webhooks y necesitás verificar el remitente (Stripe, GitHub y Shopify firman los suyos así)
- Corrés APIs máquina-a-máquina donde un secreto compartido encaja mejor que flujos de tokens OAuth
- Implementás autenticación compatible con AWS — S3, API Gateway y la mayoría de los servicios AWS usan SigV4
- Necesitás auth sin estado: ningún almacén de sesiones, solo una firma que el servidor verifica por su cuenta
Dónde es la herramienta equivocada:
- Las peticiones llevan datos que deben quedar confidenciales — HMAC firma, no cifra; igual necesitás TLS
- El cliente no puede guardar un secreto de forma segura (SPA pública, app móvil distribuida a usuarios) — mirá firmas asimétricas (Ed25519) u OAuth
- Necesitás no repudio — con un secreto compartido cualquiera de las dos partes pudo haber producido la firma
Solución
Python
import hashlib
import hmac
from datetime import datetime, timezone
def sign_request(secret: str, method: str, path: str, body: str = "") -> dict:
timestamp = datetime.now(timezone.utc).isoformat()
message = f"{method}\n{path}\n{timestamp}\n{body}"
signature = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
return {
"X-Request-Timestamp": timestamp,
"X-Request-Signature": signature,
}
# Uso del cliente
import requests
headers = sign_request("my-secret-key", "POST", "/api/orders", '{"item": "book"}')
requests.post("https://api.example.com/api/orders",
headers=headers, data='{"item": "book"}')
JavaScript
const crypto = require('crypto');
function signRequest(secret, method, path, body = '') {
const timestamp = new Date().toISOString();
const message = `${method}\n${path}\n${timestamp}\n${body}`;
const signature = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
return {
'X-Request-Timestamp': timestamp,
'X-Request-Signature': signature,
};
}
// Verificación del servidor
function verifyRequest(secret, headers, method, path, body) {
const provided = headers['x-request-signature'];
if (typeof provided !== 'string') return false;
const expected = signRequest(secret, method, path, body)['X-Request-Signature'];
const providedBuf = Buffer.from(provided);
const expectedBuf = Buffer.from(expected);
// timingSafeEqual lanza excepción si los largos difieren — chequeá primero
return providedBuf.length === expectedBuf.length
&& crypto.timingSafeEqual(providedBuf, expectedBuf);
}
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
public class RequestSigner {
private static final String HMAC_ALGO = "HmacSHA256";
// Devuelve ambos headers — el servidor necesita el timestamp
// para reconstruir el mensaje firmado
public static Map<String, String> signHeaders(
String secret, String method, String path, String body) throws Exception {
String timestamp = Instant.now().toString();
String message = String.join("\n", method, path, timestamp, body);
Mac mac = Mac.getInstance(HMAC_ALGO);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
byte[] signature = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return Map.of(
"X-Request-Timestamp", timestamp,
"X-Request-Signature", Base64.getEncoder().encodeToString(signature)
);
}
}
Explicación
El flujo es el mismo en todos los lenguajes — cuatro pasos y una trampa:
- Canonicalizá: concatená un conjunto fijo de campos — método, path, timestamp, body — en una cadena byte-exacta. Ambas partes tienen que armarla igual, y por eso la forma canónica se documenta como parte del contrato de tu API.
- Firmá: calculá
HMAC-SHA256(secreto, cadenaCanónica). HMAC envuelve el hash en una estructura ligada a la clave, que es lo que bloquea los ataques de extensión de longitud a los que un digest crudoSHA-256(secreto + mensaje)está abierto. - Adjuntá: la firma y su timestamp salen como headers — el timestamp hace doble función de protección contra replay, así que rechazá lo que pase de tu ventana.
- Verificá: reconstruí la misma cadena desde la petición que llegó, firmala, compará en tiempo constante.
La trampa: timingSafeEqual y amigos lanzan excepción ante largos distintos en lugar de devolver false. Un header de firma ausente o truncado se convierte en un 500 sin manejar si no comparás longitudes primero — el verificador en JavaScript de arriba muestra el guardia.
Variantes
| Algoritmo | Tipo de clave | Fortaleza | Caso de uso |
|---|---|---|---|
| HMAC-SHA256 | Secreto compartido | 256-bit | Autenticación de API, webhooks |
| AWS SigV4 | Credenciales IAM | 256-bit | Compatibilidad con servicios AWS |
| Ed25519 | Asimétrica | 128-bit | Verificación con clave pública/privada |
| RSA-SHA256 | Asimétrica | 2048+ bit | Integración con PKI empresarial |
HMAC-SHA256 gana en simplicidad y velocidad cuando ambos lados pueden guardar un secreto. Ed25519 o RSA tienen sentido cuando no podés distribuir la clave de firma — webhooks públicos donde los clientes publican una clave pública, o clientes a los que no les confiás una credencial compartida.
Soluciones avanzadas
Implementación de AWS Signature v4 (Python)
SigV4 envuelve a HMAC en una cadena de credenciales con scope: la clave que firma la petición se deriva por fecha, región y servicio, y la petición canónica fija exactamente qué headers y parámetros de query quedan cubiertos. Notá la normalización del query string — AWS exige parámetros URI-encoded y ordenados:
import hashlib
import hmac
import urllib.parse
from datetime import datetime, timezone
def get_aws_signature_v4(
access_key: str,
secret_key: str,
region: str,
service: str,
method: str,
url: str,
headers: dict,
body: str = '',
) -> dict:
"""Generar headers de AWS Signature v4 para una petición de API."""
parsed = urllib.parse.urlparse(url)
host = parsed.netloc
path = parsed.path or '/'
# Query canónico: URI-encode de cada nombre/valor, luego ordenar
pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
canonical_query = '&'.join(
f'{urllib.parse.quote(k, safe="")}={urllib.parse.quote(v, safe="")}'
for k, v in sorted(pairs)
)
# Paso 1: Crear la petición canónica
amz_date = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
date_stamp = amz_date[:8]
headers = dict(headers)
headers['Host'] = host # Host siempre va firmado en SigV4
signed_headers = sorted(headers.keys())
canonical_headers = ''.join(
f'{h.lower()}:{headers[h].strip()}\n' for h in signed_headers
)
signed_header_str = ';'.join(h.lower() for h in signed_headers)
payload_hash = hashlib.sha256(body.encode()).hexdigest()
canonical_request = '\n'.join([
method.upper(),
path,
canonical_query,
canonical_headers,
signed_header_str,
payload_hash,
])
# Paso 2: Crear el string a firmar
credential_scope = f'{date_stamp}/{region}/{service}/aws4_request'
string_to_sign = '\n'.join([
'AWS4-HMAC-SHA256',
amz_date,
credential_scope,
hashlib.sha256(canonical_request.encode()).hexdigest(),
])
# Paso 3: Derivar la clave de firma (cadena HMAC: fecha → región → servicio)
def sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
signing_key = sign(
sign(sign(sign(
('AWS4' + secret_key).encode(), date_stamp),
region),
service),
'aws4_request'
)
signature = hmac.new(
signing_key, string_to_sign.encode(), hashlib.sha256
).hexdigest()
# Paso 4: Construir el header de autorización
result = dict(headers)
result['Authorization'] = (
f'AWS4-HMAC-SHA256 '
f'Credential={access_key}/{credential_scope}, '
f'SignedHeaders={signed_header_str}, '
f'Signature={signature}'
)
result['X-Amz-Date'] = amz_date
result['X-Amz-Content-Sha256'] = payload_hash
return result
# Uso
headers = get_aws_signature_v4(
access_key='AKIAIOSFODNN7EXAMPLE',
secret_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
region='us-east-1',
service='s3',
method='GET',
url='https://s3.amazonaws.com/my-bucket/object.txt',
headers={},
)
En integraciones reales preferí el SDK de AWS o el firmante de botocore — un SigV4 hecho a mano existe sobre todo para tests de interoperabilidad y para entender lo que el SDK esconde.
Verificación de firma de webhook con nonce (Node.js)
Un timestamp fresco por sí solo igual deja una ventana donde una petición capturada se repite. Un nonce trackeado en Redis la cierra — cada nonce se acepta exactamente una vez:
const crypto = require('crypto');
const express = require('express');
const redis = require('redis');
const REPLAY_WINDOW = 5 * 60; // 5 minutos
const app = express();
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
async function verifyWebhook(secret, req) {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const nonce = req.headers['x-webhook-nonce'];
const body = req.body; // Buffer — necesita express.raw(), no JSON parseado
// 1. Verificar frescura del timestamp
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > REPLAY_WINDOW) {
throw new Error('Timestamp fuera de la ventana de replay');
}
// 2. Verificar unicidad del nonce — SET NX es atómico, así que dos
// peticiones idénticas que corren en paralelo no pueden pasar ambas
const nonceKey = `webhook:nonce:${nonce}`;
const created = await redisClient.set(nonceKey, '1', {
NX: true,
EX: REPLAY_WINDOW,
});
if (!created) {
throw new Error('Nonce ya usado — posible replay attack');
}
// 3. Verificar firma
const message = `${timestamp}.${nonce}.${body.toString('utf-8')}`;
const expected = crypto
.createHmac('sha256', secret)
.update(message)
.digest('base64');
const a = Buffer.from(signature || '');
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Firma inválida');
}
}
// express.raw entrega req.body como Buffer — un parser JSON cambiaría
// los bytes y rompería la verificación de la firma
app.post('/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
await verifyWebhook(process.env.WEBHOOK_SECRET, req);
res.status(200).json({ ok: true });
} catch (err) {
res.status(401).json({ error: err.message });
}
},
);
async function main() {
await redisClient.connect();
app.listen(3000);
}
main();
Firma de peticiones HMAC en Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"time"
)
func SignRequest(secret, method, path, body string) http.Header {
timestamp := time.Now().UTC().Format(time.RFC3339)
message := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, timestamp, body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
signature := hex.EncodeToString(mac.Sum(nil))
header := http.Header{}
header.Set("X-Request-Timestamp", timestamp)
header.Set("X-Request-Signature", signature)
return header
}
func VerifyRequest(secret string, r *http.Request, body []byte) bool {
timestamp := r.Header.Get("X-Request-Timestamp")
signature := r.Header.Get("X-Request-Signature")
// Verificar frescura del timestamp
ts, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return false
}
if time.Since(ts) > 5*time.Minute {
return false
}
message := fmt.Sprintf("%s\n%s\n%s\n%s",
r.Method, r.URL.Path, timestamp, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
expected := hex.EncodeToString(mac.Sum(nil))
// Comparación de tiempo constante
return hmac.Equal([]byte(signature), []byte(expected))
}
Estrategia de rotación de claves (Python)
Rotar el secreto compartido rompe cada firma en vuelo salvo que ambas claves sean válidas durante una ventana de solapamiento:
import hmac
import hashlib
import time
from dataclasses import dataclass
@dataclass
class KeyRotation:
"""Gestionar rotación de claves HMAC con período de solapamiento."""
current_key: str
previous_key: str | None = None
rotation_time: float = 0
overlap_seconds: int = 3600 # 1 hora de solapamiento
def rotate(self, new_key: str):
"""Rotar a una nueva clave, manteniendo la anterior válida durante el solapamiento."""
self.previous_key = self.current_key
self.current_key = new_key
self.rotation_time = time.time()
def get_valid_keys(self) -> list[str]:
"""Devolver todas las claves actualmente válidas."""
keys = [self.current_key]
if self.previous_key and self.previous_key != self.current_key:
if time.time() - self.rotation_time < self.overlap_seconds:
keys.append(self.previous_key)
return keys
def verify(self, signature: str, method: str, path: str,
timestamp: str, body: str) -> bool:
"""Verificar contra cualquier clave válida (soporta solapamiento de rotación)."""
message = f"{method}\n{path}\n{timestamp}\n{body}"
for key in self.get_valid_keys():
expected = hmac.new(
key.encode(), message.encode(), hashlib.sha256
).hexdigest()
if hmac.compare_digest(signature, expected):
return True
return False
# Uso
rotation = KeyRotation(current_key='secret-v1')
# Después de rotar: tanto v1 como v2 son válidas por 1 hora
rotation.rotate('secret-v2')
# Después de 1 hora: solo v2 es válida
Lo que funciona
- Compará con una primitiva de tiempo constante (
timingSafeEqual,hmac.compare_digest,hmac.Equal) — y chequeá los largos primero, porque lanzan excepción si difieren. - Firmá una cadena canónica que incluya el timestamp y el body crudo; rechazá lo que pase de ~5 minutos para acotar el replay.
- Rotá secretos con una ventana de solapamiento donde la clave vieja y la nueva verifican — un corte único deja fuera a todos los clientes a mitad de request.
- Guardá un secreto por cliente: un cliente comprometido rota solo en vez de forzar un cambio global de clave.
- Almacená los secretos en un vault (HashiCorp Vault o AWS Secrets Manager), nunca en código ni archivos de config.
Errores comunes
- MD5 o SHA1 como hash. Los dos están criptográficamente rotos — SHA-256 es el piso.
- Comparación con
==plano. La igualdad de strings corta en el primer byte distinto y filtra información de timing; usá la comparación de tiempo constante. - Firmar solo los headers. Cuando el body no está en la cadena canónica, el payload se puede reescribir sin que la firma lo note.
- Sin ventana de replay. Una firma sin timestamp significa que una petición capturada se repite para siempre.
- Olvidar que
timingSafeEqualnecesita largos iguales. Una firma truncada lanza excepción en vez de devolver false — primero el guardia de longitud. - JSON parseado como body firmado. Re-serializar JSON cambia los bytes (orden de claves, espacios); siempre firmá y verificá el body crudo de la petición.
Lecturas adicionales
- RFC 2104 — HMAC y FIPS 198-1
- Referencia de AWS Signature Version 4
- OWASP REST Security Cheat Sheet
- Código complementario: el repo companion de firma de peticiones tiene pares firma/verificación ejecutables en los cuatro lenguajes, el firmante SigV4 y el verificador de webhooks
- API Security Headers — los headers que complementan la firma de peticiones
- Checklist de seguridad de APIs — dónde encaja la firma en el endurecimiento completo de una API
Preguntas frecuentes
¿Qué va dentro de la cadena que firmo?
Lo que el servidor pueda reconstruir byte por byte desde la petición recibida: típicamente método, path, parámetros de query ordenados, timestamp y el body crudo. La forma canónica exacta es parte del contrato de tu API — documentala, porque cualquier campo no documentado obliga a los clientes a adivinar.
¿Por qué HMAC necesita un timestamp?
Las firmas prueban autenticidad, no frescura. Sin timestamp, un atacante que capture una petición válida la puede repetir indefinidamente. Una ventana de 5 minutos más un nonce te da ambos límites: las peticiones viejas mueren y los nonces idénticos dentro de la ventana chocan.
¿Cómo previenen ataques las comparaciones de tiempo constante?
== sale en el primer byte distinto, así que el tiempo de respuesta filtra cuántos bytes iniciales estaban bien. Un atacante mide esa fuga y reconstruye una firma válida byte a byte. Las funciones tipo timingSafeEqual comparan cada byte en tiempo fijo — solo recordá que lanzan excepción con largos distintos, así que el guardia va primero.
¿HMAC o firmas asimétricas (Ed25519): cuándo cada una?
HMAC cuando ambas partes pueden guardar el mismo secreto — servidor-a-servidor, tus propios webhooks. Asimétricas cuando el firmante no puede confiarse una credencial compartida: un cliente externo, una integración pública, o donde necesitás no repudio (el verificador guarda la clave pública y no puede falsificar).
¿La firma HMAC alcanza sin HTTPS?
No. HMAC firma la petición — no la cifra, así que cualquiera en el camino lee el body igual. Firmar sin TLS te da integridad con cero confidencialidad, y la protección contra replay sigue dependiendo de tu ventana de timestamp.
¿Cómo roto las claves de firma sin downtime?
Aceptá la clave vieja y la nueva durante una ventana de solapamiento — los clientes pasan al secreto nuevo cuando lo reciben y las peticiones en vuelo firmadas con la vieja igual verifican. Cerrá la ventana después del solapamiento (una hora típico) y retirá la clave vieja. La clase KeyRotation de arriba implementa exactamente eso.
Recursos Relacionados
Proteger APIs con encabezados de seguridad HTTP
Cómo configurar headers de seguridad esenciales como HSTS, CSP y X-Frame-Options para proteger APIs y aplicaciones web de ataques comunes.
RecipeEncripción en Reposo para Bases de Datos y Almacenamiento
Cómo encriptar datos sensibles antes de almacenarlos en bases de datos, object storage y backups usando AES-256-GCM, encripción de sobre y servicios de gestión de keys.
RecipeMicroservicios Event-Driven
Diseña microservicios event-driven con message brokers, event sourcing, CQRS y patrones de consistencia eventual.
RecipeCredenciales en Vivo de Base de Datos con HashiCorp Vault
Como usar HashiCorp Vault para generar credenciales de base de datos de corta duracion, eliminando passwords hardcodeados y reduciendo secret sprawl
GuideChecklist 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.
GuideOAuth2 y OIDC: Authorization Code, PKCE, Token Validation
Dominá OAuth2 y OpenID Connect para producción: authorization code flow con PKCE, token validation, refresh tokens, scopes, JWT verification y security best practices.