Verifying webhook signatures

Every webhook Verto delivers carries one or more auth-* headers. These allow you to verify that the request genuinely came from Verto before acting on the payload.


How it works

Each company has up to two live certificates. For every webhook delivery, Verto constructs a short token:

base64(companyId) + "||" + <UTC day of month>

This token is encrypted once per live certificate using RSA-OAEP with SHA-512 (SHA-512 is used for both the OAEP digest and the MGF1 digest). Each ciphertext is Base64-encoded and attached as a numbered header:

auth-0: <base64 ciphertext, encrypted with cert #1>
auth-1: <base64 ciphertext, encrypted with cert #2>

You hold the matching private keys (PKCS#8 PEM), downloaded from the platform under webhook certificates. Your key decrypts exactly one of the headers — the others will fail to decrypt. That failure is expected and not an error. Try every header and private key pair, and accept the request on the first pair that decrypts and validates successfully.


Verification steps

  1. Collect every request header whose name starts with auth- (case-insensitive)
  2. For each header and each private key you hold, attempt an RSA-OAEP/SHA-512 decrypt — swallow failures and keep going
  3. On a successful decrypt, split the plaintext on ||
  4. Base64-decode the first part and compare it to your expected company ID using a constant-time comparison
  5. Check the second part against the current UTC day of month — allow yesterday and tomorrow to absorb clock skew, midnight rollover, and delivery retries. Derive neighbouring days from real dates so month boundaries wrap correctly; do not use day ± 1 arithmetic
  6. If no pair passes, reject the request

Code samples

import * as crypto from 'crypto'

const AUTH_HEADER_PREFIX = 'auth-'

export interface AuthVerificationResult {
  valid: boolean
  companyId?: string
  matchedHeader?: string
}

export function verifyWebhookAuth(
  headers: Record<string, string | string[] | undefined>,
  privateKeyPems: string[],
  expectedCompanyId: string
): AuthVerificationResult {
  for (const [name, rawValue] of Object.entries(headers)) {
    if (!name.toLowerCase().startsWith(AUTH_HEADER_PREFIX)) continue

    const value = Array.isArray(rawValue) ? rawValue[0] : rawValue
    if (!value) continue

    for (const pem of privateKeyPems) {
      const plaintext = tryDecrypt(value, pem)
      if (plaintext === null) continue

      const separator = plaintext.indexOf('||')
      if (separator < 0) continue

      const companyId = Buffer.from(plaintext.slice(0, separator), 'base64').toString('utf8')
      if (!safeEquals(companyId, expectedCompanyId)) continue
      if (!isAcceptableUtcDay(plaintext.slice(separator + 2))) continue

      return { valid: true, companyId, matchedHeader: name }
    }
  }

  return { valid: false }
}

function tryDecrypt(base64Ciphertext: string, privateKeyPem: string): string | null {
  try {
    const decrypted = crypto.privateDecrypt(
      { key: privateKeyPem, oaepHash: 'sha512' },
      Buffer.from(base64Ciphertext, 'base64')
    )
    return decrypted.toString('utf8')
  } catch {
    return null
  }
}

function safeEquals(a: string, b: string): boolean {
  const bufA = Buffer.from(a, 'utf8')
  const bufB = Buffer.from(b, 'utf8')
  if (bufA.length !== bufB.length) return false
  return crypto.timingSafeEqual(bufA, bufB)
}

function isAcceptableUtcDay(dayPart: string): boolean {
  const oneDayMs = 24 * 60 * 60 * 1000
  const now = Date.now()
  return [now - oneDayMs, now, now + oneDayMs]
    .map((ms) => new Date(ms).getUTCDate().toString())
    .includes(dayPart)
}

Common pitfalls

MGF1 digest — OAEP requires two digest algorithms. Verto uses SHA-512 for both. Node's oaepHash: 'sha512' and Python's explicit MGF1(SHA512()) handle this correctly. Java's shorthand transformation string (OAEPWithSHA-512AndMGF1Padding) silently defaults MGF1 to SHA-1 and will never decrypt Verto's ciphertexts — always build an OAEPParameterSpec by hand as shown above.

Failed decrypts are normal — with two live certificates and one private key, roughly half the auth-* headers will not decrypt. Do not log these as errors or abort on the first failure — keep trying all combinations.

Ciphertext size — keys are RSA-4096, so each ciphertext is 512 bytes (approximately 684 characters Base64-encoded). Ensure your ingress does not truncate headers of this size.

Day of month, not a full date — the token carries a value from 1 to 31 which repeats monthly. It is a freshness hint, not a nonce.

Not replay protection — anyone who captures a delivery can resend it within the accepted window. Always serve your webhook endpoint over HTTPS and deduplicate on the event ID in the payload before applying side effects.

Certificate rotation — creating a new certificate keeps the two most recent and deletes the rest. Add the new private key to your list before rotating, and remove the retired one only after deliveries stop matching it.


Did this page help you?