breezeauth.com
ES
Iniciar sesiónEmpieza gratis
WebhooksVerificar firmas

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.

En esta página
  1. Standard Webhooks
  2. Cabeceras enviadas con cada mensaje
  3. Qué se firma
  4. Claves públicas
  5. Caché y rotación de claves
  6. Paso a paso
  7. Ejemplos de código
  8. Node.js
  9. Python
  10. Permitir las direcciones IP de BreezeAuth
  11. Sobre el timestamp
  12. Errores comunes

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-keyid que 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

  1. Lee el cuerpo sin procesar de la petición y las cabeceras webhook-id, webhook-timestamp, webhook-signature y webhook-keyid. Si falta alguna, rechaza la petición.
  2. Busca la clave pública cuyo kid sea igual a webhook-keyid, actualizando la caché del JWKS si hace falta. Si no existe ninguna, rechaza la petición.
  3. Construye el contenido firmado: {webhook-id}.{webhook-timestamp}.{cuerpo sin procesar}.
  4. Para cada entrada v1a,… de webhook-signature, decodifica la firma en base64 y verifícala contra el contenido firmado con la clave pública, usando Ed25519.
  5. 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 son v1a (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-keyid desconocido.
  • 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