Verificar las firmas de los webhooks
Cómo firma BreezeAuth cada mensaje siguiendo Standard Webhooks, cómo debe comprobar tu endpoint la firma antes de confiar en él y qué direcciones IP permitir.
Tu endpoint de webhook tiene que ser accesible desde internet para que BreezeAuth pueda entregarle los mensajes. Salvo que restrinjas quién puede acceder a él, cualquiera que descubra su URL puede enviarle peticiones, incluidos mensajes falsificados. BreezeAuth te ofrece dos capas de protección:
- Firmas. BreezeAuth firma cada mensaje con una clave privada que solo BreezeAuth posee. Tu endpoint comprueba la firma con la clave pública correspondiente, que BreezeAuth publica. Es obligatorio.
- Direcciones IP fijas. BreezeAuth envía los webhooks desde un conjunto publicado de direcciones IP, que puedes permitir en tu cortafuegos para que las peticiones de cualquier otro origen nunca lleguen a tu endpoint. Es opcional, y recomendable.
Importante: verifica la firma antes de confiar en cualquier contenido del mensaje. Un mensaje con la firma ausente o no válida debe rechazarse y no procesarse nunca.
Esta página es técnica. Si estás configurando webhooks en la consola de administración, compártela con los desarrolladores que vayan a construir tu endpoint.
Standard Webhooks
BreezeAuth sigue la especificación Standard Webhooks, un estándar abierto para el
envío de webhooks que define las cabeceras, el contenido firmado y el formato de la firma. BreezeAuth utiliza el
esquema asimétrico de la especificación, identificado por el prefijo v1a, con firmas Ed25519.
Las firmas asimétricas implican que no hay ningún secreto compartido que configurar, guardar o rotar por tu parte: solo necesitas las claves públicas de BreezeAuth, que son públicas por definición.
Además del estándar, BreezeAuth añade una cabecera no estándar, webhook-keyid, que indica qué clave pública se
usó para firmar el mensaje. Así la búsqueda de la clave es directa y la rotación de claves, transparente.
Cabeceras enviadas con cada mensaje
| Cabecera | Ejemplo | Descripción |
|---|---|---|
webhook-id |
6c1f0f8e-4f5b-4a53-9d0e-2b7c9a1e3f42 |
Identificador único del mensaje. Mismo valor que uuid en el cuerpo, e igual en todos los reintentos. |
webhook-timestamp |
1791901925 |
Cuándo ocurrió el evento, en segundos desde la época Unix. Mismo instante que timestamp en el cuerpo, e igual en todos los reintentos. |
webhook-signature |
v1a,K5oZfzN95Z9UVu1EsfQmfVNQ… |
La firma, precedida de su versión (v1a). Codificada en base64. |
webhook-keyid |
2026-10-webhooks |
Extensión de BreezeAuth. Identificador (kid) de la clave que generó la firma. |
Content-Type |
application/json |
El cuerpo siempre es JSON. |
La cabecera webhook-signature sigue el formato del estándar: una lista de entradas versión,firma separadas por
espacios. Hoy BreezeAuth envía una única entrada v1a, pero tu código debe aceptar una lista y considerar válido el
mensaje si cualquier entrada v1a se verifica.
Qué se firma
La firma cubre el identificador del mensaje, el timestamp y el cuerpo exacto, unidos por puntos:
{webhook-id}.{webhook-timestamp}.{cuerpo sin procesar de la petición}
Por ejemplo:
6c1f0f8e-4f5b-4a53-9d0e-2b7c9a1e3f42.1791901925.{"uuid":"6c1f0f8e-4f5b-4a53-9d0e-2b7c9a1e3f42","organizationsUuid":…}
BreezeAuth firma estos bytes con Ed25519 y coloca el resultado, codificado en base64, en webhook-signature, después
de v1a,.
Importante: usa los bytes del cuerpo sin procesar tal como se recibieron. Si tu framework interpreta el JSON y vuelves a serializarlo, los espacios o el orden de las claves pueden cambiar y la firma dejará de coincidir. La mayoría de los frameworks permiten leer el cuerpo sin procesar; los ejemplos de abajo muestran cómo.
Claves públicas
BreezeAuth publica las claves públicas de los webhooks como un JSON Web Key Set (JWKS) en:
https://well-known.breezeauth.com/webhooks/jwks.json
El archivo contiene una o varias claves Ed25519, cada una identificada por su kid:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"kid": "2026-10-webhooks",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}
]
}
Usa la clave cuyo kid coincida con la cabecera webhook-keyid del mensaje.
Caché y rotación de claves
BreezeAuth rota sus claves de firma periódicamente. Las claves nuevas se publican en el JWKS antes de empezar a usarse, y las antiguas siguen publicadas durante un tiempo después de dejar de usarse. Para gestionarlo sin interrupciones:
- Guarda el JWKS en caché en memoria; no hace falta descargarlo con cada mensaje.
- Cuando llegue un mensaje con un
webhook-keyidque no está en tu caché, vuelve a descargar el JWKS una vez y busca de nuevo la clave. - Limita estas actualizaciones, por ejemplo a una por minuto como máximo, para que mensajes falsificados con identificadores de clave aleatorios no hagan que tu endpoint sature la URL del JWKS.
- Si la clave sigue sin conocerse después de actualizar, rechaza el mensaje.
Paso a paso
- Lee el cuerpo sin procesar de la petición y las cabeceras
webhook-id,webhook-timestamp,webhook-signatureywebhook-keyid. Si falta alguna, rechaza la petición. - Busca la clave pública cuyo
kidsea igual awebhook-keyid, actualizando la caché del JWKS si hace falta. Si no existe ninguna, rechaza la petición. - Construye el contenido firmado:
{webhook-id}.{webhook-timestamp}.{cuerpo sin procesar}. - Para cada entrada
v1a,…dewebhook-signature, decodifica la firma en base64 y verifícala contra el contenido firmado con la clave pública, usando Ed25519. - Si ninguna entrada se verifica, rechaza la petición con un estado
4xx. Si no, el mensaje es legítimo: continúa con las comprobaciones descritas en Recibir eventos de forma fiable.
Ejemplos de código
Muchas bibliotecas de Standard Webhooks solo implementan el esquema simétrico v1 (HMAC con un secreto compartido). A
menos que tu biblioteca admita explícitamente v1a con claves públicas Ed25519, verifica la firma con la biblioteca de
criptografía de tu lenguaje, como en los ejemplos siguientes.
Node.js
Usa solo el módulo integrado node:crypto (Node.js 18 o posterior) y Express.
import express from 'express'
import { createPublicKey, verify } from 'node:crypto'
const JWKS_URL = 'https://well-known.breezeauth.com/webhooks/jwks.json'
const JWKS_REFRESH_COOLDOWN_MS = 60_000
let publicKeys = new Map()
let lastRefreshAt = 0
async function getPublicKey(keyId) {
if (!publicKeys.has(keyId) && Date.now() - lastRefreshAt > JWKS_REFRESH_COOLDOWN_MS) {
// Clave desconocida: puede que BreezeAuth haya rotado sus claves, así que actualiza la caché (como máximo una vez por minuto).
lastRefreshAt = Date.now()
const { keys } = await fetch(JWKS_URL).then((response) => response.json())
publicKeys = new Map(keys.map((jwk) => [jwk.kid, createPublicKey({ key: jwk, format: 'jwk' })]))
}
return publicKeys.get(keyId)
}
export async function isValidSignature(rawBody, headers) {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const signatureHeader = headers['webhook-signature']
const keyId = headers['webhook-keyid']
if (!id || !timestamp || !signatureHeader || !keyId) return false
const publicKey = await getPublicKey(keyId)
if (!publicKey) return false
const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody])
// La cabecera puede contener varias firmas separadas por espacios; basta con una firma v1a válida.
return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',')
return version === 'v1a' && !!signature && verify(null, signedContent, publicKey, Buffer.from(signature, 'base64'))
})
}
const app = express()
// express.raw() conserva el cuerpo como Buffer, tal como se recibió.
app.post('/webhooks/breezeauth', express.raw({ type: 'application/json' }), async (req, res) => {
if (!(await isValidSignature(req.body, req.headers))) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
// Siguientes pasos: comprobar event.organizationsUuid, ignorar duplicados, encolar, responder 2xx.
// Consulta "Recibir eventos de forma fiable".
res.status(204).end()
})
Python
Usa Flask, requests y cryptography.
import base64
import json
import time
import requests
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from flask import Flask, request
JWKS_URL = "https://well-known.breezeauth.com/webhooks/jwks.json"
JWKS_REFRESH_COOLDOWN_SECONDS = 60
public_keys = {}
last_refresh_at = 0.0
def b64url_decode(value):
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def get_public_key(key_id):
global public_keys, last_refresh_at
if key_id not in public_keys and time.time() - last_refresh_at > JWKS_REFRESH_COOLDOWN_SECONDS:
# Clave desconocida: puede que BreezeAuth haya rotado sus claves, así que actualiza la caché (como máximo una vez por minuto).
last_refresh_at = time.time()
jwks = requests.get(JWKS_URL, timeout=5).json()
public_keys = {
jwk["kid"]: Ed25519PublicKey.from_public_bytes(b64url_decode(jwk["x"]))
for jwk in jwks["keys"]
if jwk.get("kty") == "OKP" and jwk.get("crv") == "Ed25519"
}
return public_keys.get(key_id)
def is_valid_signature(raw_body, headers):
message_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
key_id = headers.get("webhook-keyid")
if not all([message_id, timestamp, signature_header, key_id]):
return False
public_key = get_public_key(key_id)
if public_key is None:
return False
signed_content = f"{message_id}.{timestamp}.".encode() + raw_body
# La cabecera puede contener varias firmas separadas por espacios; basta con una firma v1a válida.
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version != "v1a" or not signature:
continue
try:
public_key.verify(base64.b64decode(signature), signed_content)
return True
except (InvalidSignature, ValueError):
continue
return False
app = Flask(__name__)
@app.post("/webhooks/breezeauth")
def breezeauth_webhook():
raw_body = request.get_data() # el cuerpo tal como se recibió
if not is_valid_signature(raw_body, request.headers):
return "", 401
event = json.loads(raw_body)
# Siguientes pasos: comprobar event["organizationsUuid"], ignorar duplicados, encolar, responder 2xx.
# Consulta "Recibir eventos de forma fiable".
return "", 204
Permitir las direcciones IP de BreezeAuth
Para añadir una capa extra de protección, configura tu cortafuegos, balanceador de carga o cortafuegos de aplicaciones web (WAF) para que acepte peticiones a tu endpoint de webhook solo desde las direcciones IP desde las que BreezeAuth envía los webhooks (una lista de direcciones permitidas, o allowlist):
3.226.82.18
3.227.201.231
Así, las peticiones de cualquier otra dirección se bloquean antes de llegar a tu aplicación. Esto oculta tu endpoint al resto de internet, elimina el tráfico no deseado y frena los mensajes falsificados antes de que tu código tenga que rechazarlos. Si el cortafuegos lo gestiona tu equipo de redes o de TI, comparte esta lista con ellos.
Importante: la lista de direcciones permitidas complementa la verificación de la firma; no la sustituye. Sigue verificando todas las firmas y comprobando la organización: los mensajes de todas las organizaciones de BreezeAuth llegan desde estas mismas direcciones.
Nota: hoy son dos direcciones, y podrían añadirse más a medida que crezca la infraestructura de BreezeAuth. Mantén tu lista de direcciones permitidas fácil de actualizar y consulta esta página con regularidad. Si se bloquean las peticiones de BreezeAuth, las entregas fallan y aparecen en el historial de eventos como tiempos de espera agotados o errores de conexión.
Si tu endpoint está detrás de un proxy inverso, una CDN o un balanceador de carga, aplica la regla donde se vea la
dirección original del cliente: en el propio borde, o usando la dirección del cliente que reenvía un proxy que tú
controlas. No confíes nunca en una cabecera X-Forwarded-For que llegue directamente de internet, porque cualquiera
puede establecerla.
Sobre el timestamp
La especificación Standard Webhooks sugiere rechazar los mensajes con un timestamp demasiado antiguo, como protección
frente a mensajes reenviados por terceros (replay). Si aplicas esa tolerancia, ten en cuenta que webhook-timestamp
es el momento en que ocurrió el evento, no el del intento de entrega, y que no cambia entre
reintentos. Usa un margen amplio, de varios minutos como mínimo, para no rechazar
reintentos legítimos, y confía en la detección de duplicados como tu
principal protección frente a reenvíos.
Errores comunes
- Verificar un cuerpo serializado de nuevo. Verifica siempre los bytes sin procesar, antes de interpretar el JSON.
- Usar una biblioteca
v1(HMAC). Las firmas de BreezeAuth sonv1a(Ed25519). No hay ningún secreto compartido. - Descargar el JWKS en cada petición. Guárdalo en caché y actualízalo solo cuando aparezca un
webhook-keyiddesconocido. - Confiar solo en la lista de direcciones IP permitidas. Reduce la exposición, pero solo la firma demuestra que el mensaje es legítimo y no se ha alterado.
- Confiar en el mensaje nada más verificar la firma. La firma demuestra que el mensaje viene de BreezeAuth; todavía tienes que comprobar la organización a la que va dirigido.
Última actualización: 13 de octubre de 2026