breezeauth.com
EN
Sign inGet started free
WebhooksVerify signatures

Verify webhook signatures

How BreezeAuth signs every message following Standard Webhooks, how your endpoint must check the signature before trusting it, and which IP addresses to allowlist.

On this page
  1. Standard Webhooks
  2. Headers sent with every message
  3. What is signed
  4. Public keys
  5. Caching and key rotation
  6. Step by step
  7. Code examples
  8. Node.js
  9. Python
  10. Allowlist BreezeAuth IP addresses
  11. About the timestamp
  12. Common mistakes

Your webhook endpoint must be reachable over the internet so that BreezeAuth can deliver messages to it. Unless you restrict who can reach it, anyone who discovers its URL can send it requests, including forged messages. BreezeAuth gives you two layers of protection:

  • Signatures. BreezeAuth signs every message with a private key that only BreezeAuth holds. Your endpoint checks the signature with the matching public key, which BreezeAuth publishes. This is mandatory.
  • Fixed IP addresses. BreezeAuth sends webhooks from a published set of IP addresses, which you can allowlist in your firewall so that requests from anywhere else never reach your endpoint. This is optional, and recommended.

Important: verify the signature before trusting anything in the message. A message whose signature is missing or invalid must be rejected and never processed.

This page is technical. If you are configuring webhooks in the admin console, share it with the developers building your endpoint.

Standard Webhooks

BreezeAuth follows the Standard Webhooks specification, an open standard for sending webhooks that defines the headers, the signed content and the signature format. BreezeAuth uses the specification’s asymmetric scheme, identified by the v1a prefix, with Ed25519 signatures.

Asymmetric signatures mean there is no shared secret to configure, store or rotate on your side: you only need BreezeAuth’s public keys, which are public by design.

On top of the standard, BreezeAuth adds one non-standard header, webhook-keyid, which tells you which public key was used to sign the message. It makes key lookup direct and key rotation seamless.

Headers sent with every message

Header Example Description
webhook-id 6c1f0f8e-4f5b-4a53-9d0e-2b7c9a1e3f42 Unique message identifier. Same value as uuid in the body, and the same across retries.
webhook-timestamp 1791901925 When the event happened, in seconds since the Unix epoch. Same instant as timestamp in the body, and the same across retries.
webhook-signature v1a,K5oZfzN95Z9UVu1EsfQmfVNQ… The signature, prefixed with its version (v1a). Base64 encoded.
webhook-keyid 2026-10-webhooks BreezeAuth extension. Identifier (kid) of the key that produced the signature.
Content-Type application/json The body is always JSON.

The webhook-signature header follows the standard format: a space-separated list of version,signature entries. Today BreezeAuth sends a single v1a entry, but your code should accept a list and consider the message valid if any v1a entry verifies.

What is signed

The signature covers the message identifier, the timestamp and the exact body, joined with dots:

{webhook-id}.{webhook-timestamp}.{raw request body}

For example:

6c1f0f8e-4f5b-4a53-9d0e-2b7c9a1e3f42.1791901925.{"uuid":"6c1f0f8e-4f5b-4a53-9d0e-2b7c9a1e3f42","organizationsUuid":…}

BreezeAuth signs these bytes with Ed25519 and puts the base64-encoded result in webhook-signature, after v1a,.

Important: use the raw body bytes exactly as received. If your framework parses the JSON and you serialize it again, spacing or key order can change and the signature will no longer match. Most frameworks offer a way to read the raw body; the examples below show how.

Public keys

BreezeAuth publishes its webhook public keys as a JSON Web Key Set (JWKS) at:

https://well-known.breezeauth.com/webhooks/jwks.json

The file contains one or more Ed25519 keys, each identified by its kid:

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "alg": "EdDSA",
      "kid": "2026-10-webhooks",
      "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
    }
  ]
}

Pick the key whose kid matches the webhook-keyid header of the message.

Caching and key rotation

BreezeAuth rotates its signing keys periodically. New keys are published in the JWKS before they are used, and old keys remain published for a while after they stop being used. To handle this without downtime:

  • Cache the JWKS in memory; there is no need to download it for every message.
  • When a message arrives with a webhook-keyid that is not in your cache, download the JWKS again once, then look the key up again.
  • Limit these refreshes, for example to at most one per minute, so that forged messages with random key IDs cannot make your endpoint flood the JWKS URL.
  • If the key is still unknown after a refresh, reject the message.

Step by step

  1. Read the raw request body and the webhook-id, webhook-timestamp, webhook-signature and webhook-keyid headers. If any of them is missing, reject the request.
  2. Find the public key whose kid equals webhook-keyid, refreshing your JWKS cache if needed. If there is none, reject the request.
  3. Build the signed content: {webhook-id}.{webhook-timestamp}.{raw body}.
  4. For each v1a,… entry in webhook-signature, decode the base64 signature and verify it against the signed content with the public key, using Ed25519.
  5. If no entry verifies, reject the request with a 4xx status. Otherwise, the message is genuine: continue with the checks described in Receive events reliably.

Code examples

Many Standard Webhooks libraries only implement the symmetric v1 scheme (HMAC with a shared secret). Unless your library explicitly supports v1a with Ed25519 public keys, verify the signature with your language’s cryptography library, as in the examples below.

Node.js

Uses only the built-in node:crypto module (Node.js 18 or later) and 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) {
    // Unknown key: BreezeAuth may have rotated its keys, so refresh the cache (at most once a minute).
    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])

  // The header may contain several space-separated signatures; one valid v1a signature is enough.
  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() keeps the body as a Buffer, exactly as received.
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'))
  // Next: check event.organizationsUuid, ignore duplicates, enqueue, reply 2xx.
  // See "Receive events reliably".
  res.status(204).end()
})

Python

Uses Flask, requests and 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:
        # Unknown key: BreezeAuth may have rotated its keys, so refresh the cache (at most once a minute).
        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

    # The header may contain several space-separated signatures; one valid v1a signature is enough.
    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()  # the body exactly as received
    if not is_valid_signature(raw_body, request.headers):
        return "", 401

    event = json.loads(raw_body)
    # Next: check event["organizationsUuid"], ignore duplicates, enqueue, reply 2xx.
    # See "Receive events reliably".
    return "", 204

Allowlist BreezeAuth IP addresses

For an extra layer of protection, configure your firewall, load balancer or web application firewall (WAF) to accept requests to your webhook endpoint only from the IP addresses BreezeAuth sends webhooks from:

3.226.82.18
3.227.201.231

Requests from any other address are then blocked before they reach your application. This hides your endpoint from the rest of the internet, cuts out unwanted traffic, and stops forged messages before your code has to reject them. If your network or IT team manages the firewall, share this list with them.

Important: the allowlist complements signature verification; it does not replace it. Keep verifying every signature and checking the organization: messages from every BreezeAuth organization come from these same addresses.

Note: there are two addresses today, and more may be added as BreezeAuth’s infrastructure grows. Keep your allowlist easy to update and check this page regularly. If BreezeAuth’s requests are blocked, deliveries fail and show up in the event history as timeouts or connection errors.

If your endpoint sits behind a reverse proxy, CDN or load balancer, apply the rule where the original client address is visible: at the edge itself, or using the client address forwarded by a proxy you control. Never trust an X-Forwarded-For header that comes straight from the internet, because anyone can set it.

About the timestamp

The Standard Webhooks specification suggests rejecting messages whose timestamp is too old, as a protection against replayed messages. If you apply such a tolerance, keep in mind that webhook-timestamp is the moment the event happened, not the moment of the delivery attempt, and that it stays the same across retries. Use a generous window, of several minutes at least, so legitimate retries are not rejected, and rely on duplicate detection as your main protection against replays.

Common mistakes

  • Verifying a re-serialized body. Always verify the raw bytes, before any JSON parsing.
  • Using a v1 (HMAC) library. BreezeAuth signatures are v1a (Ed25519). There is no shared secret.
  • Downloading the JWKS on every request. Cache it and refresh only when an unknown webhook-keyid appears.
  • Relying on the IP allowlist alone. It reduces exposure, but only the signature proves a message is genuine and unaltered.
  • Trusting the message right after verifying the signature. The signature proves the message comes from BreezeAuth; you still need to check the organization it is for.

Last updated: October 13, 2026