StackPractices
advanced Por Mathias Paulenko

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.

Temas: security

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:

flowchart diagram: El cliente arma la cadena canónica:<br/>método + path + timestamp + body
  1. 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.
  2. 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 crudo SHA-256(secreto + mensaje) está abierto.
  3. 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.
  4. 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

AlgoritmoTipo de claveFortalezaCaso de uso
HMAC-SHA256Secreto compartido256-bitAutenticación de API, webhooks
AWS SigV4Credenciales IAM256-bitCompatibilidad con servicios AWS
Ed25519Asimétrica128-bitVerificación con clave pública/privada
RSA-SHA256Asimétrica2048+ bitIntegració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 timingSafeEqual necesita 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

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.