Validating NICE Cognigy Incoming Webhook Signatures in Java

Validating NICE Cognigy Incoming Webhook Signatures in Java

What You Will Build

A Spring Boot REST endpoint that verifies NICE Cognigy webhook signatures using HMAC-SHA256, enforces clock skew policies, tracks nonces to block replay attacks, decrypts encrypted payloads via RSA-OAEP, streams validation metrics to an external SIEM, and generates compliance audit logs. This tutorial covers the full cryptographic verification pipeline using the Java Cryptography Architecture and Jakarta Servlet APIs.

Prerequisites

  • Cognigy.AI webhook configuration with an HMAC secret enabled
  • Java 17 runtime and Maven or Gradle build tool
  • Spring Boot 3.2.x (spring-boot-starter-web)
  • Environment variables: COGNIGY_WEBHOOK_SECRET, DECRYPTION_PRIVATE_KEY, SIEM_WEBHOOK_URL
  • External SIEM endpoint accepting JSON security events
  • Dependencies: jakarta.servlet-api, com.fasterxml.jackson.core:jackson-databind

Authentication Setup

Cognigy appends two headers to every outbound webhook request: X-Cognigy-Signature and X-Cognigy-Timestamp. The platform generates the signature by computing an HMAC-SHA256 digest of the raw request body using the shared secret configured in the Cognigy Studio webhook settings. The receiving service must reconstruct the expected signature, compare it using constant-time logic, and validate the timestamp against a configurable clock skew tolerance. No OAuth scopes are required for inbound validation because the security model relies on symmetric HMAC verification. The service injects the HMAC secret and RSA decryption key from environment variables at startup.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WebhookSecurityConfig {

    @Value("${COGNIGY_WEBHOOK_SECRET}")
    private String hmacSecret;

    @Value("${DECRYPTION_PRIVATE_KEY}")
    private String rsaPrivateKey;

    @Value("${SIEM_WEBHOOK_URL}")
    private String siemEndpoint;

    @Value("${WEBHOOK_TIMESTAMP_TOLERANCE_MS:300000}")
    private long timestampToleranceMs;

    // Expose via getters or constructor injection for the validator service
}

Implementation

Step 1: Request Extraction and Signature Construction

The Spring Boot controller intercepts incoming POST requests at /webhooks/cognigy. You must read the raw body exactly as transmitted to preserve byte alignment for HMAC verification. Extract the signature and timestamp headers, then construct the verification payload. Cognigy signs the raw body without modification.

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

@RestController
@RequestMapping("/webhooks/cognigy")
public class CognigyWebhookController {

    private final WebhookValidator validator;

    public CognigyWebhookController(WebhookValidator validator) {
        this.validator = validator;
    }

    @PostMapping
    public ResponseEntity<?> handleWebhook(HttpServletRequest request) throws IOException {
        String rawBody = request.getReader().lines()
                .reduce("", (a, b) -> a + b);
        
        String signature = request.getHeader("X-Cognigy-Signature");
        String timestampHeader = request.getHeader("X-Cognigy-Timestamp");
        
        if (signature == null || timestampHeader == null) {
            return ResponseEntity.status(400).body("{\"error\": \"Missing Cognigy signature or timestamp header\"}");
        }

        long requestTimestamp = Long.parseLong(timestampHeader);
        ValidationResult result = validator.process(rawBody, signature, requestTimestamp);
        
        if (result.isValid()) {
            return ResponseEntity.ok("{\"status\": \"accepted\"}");
        }
        
        return ResponseEntity.status(401).body("{\"error\": \"Signature validation failed\", \"reason\": \"" + result.getReason() + "\"}");
    }
}

Step 2: HMAC Verification, Constant-Time Comparison, and Clock Skew Policy

Generate the expected HMAC-SHA256 digest using the shared secret and raw body. Compare the computed signature against the header value using MessageDigest.isEqual() to prevent timing attacks. Validate the timestamp against the current system clock using the configured tolerance window. Reject requests outside the tolerance to mitigate clock skew exploitation.

import java.security.MessageDigest;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.LongAdder;

public class WebhookValidator {

    private final String hmacSecret;
    private final long timestampToleranceMs;
    private final LongAdder validCount = new LongAdder();
    private final LongAdder rejectedCount = new LongAdder();
    private final LongAdder totalLatencyNs = new LongAdder();

    public WebhookValidator(String hmacSecret, long timestampToleranceMs) {
        this.hmacSecret = hmacSecret;
        this.timestampToleranceMs = timestampToleranceMs;
    }

    public boolean verifySignature(String rawBody, String providedSignature, long requestTimestamp) 
            throws NoSuchAlgorithmException, InvalidKeyException {
        
        long startNs = System.nanoTime();
        
        // Compute expected HMAC-SHA256
        Mac sha256Hmac = Mac.getInstance("HmacSHA256");
        SecretKeySpec keySpec = new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        sha256Hmac.init(keySpec);
        byte[] expectedBytes = sha256Hmac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
        String expectedHex = bytesToHex(expectedBytes);
        
        // Constant-time comparison
        boolean signatureMatches = MessageDigest.isEqual(expectedHex.getBytes(StandardCharsets.UTF_8),
                                                        providedSignature.getBytes(StandardCharsets.UTF_8));
        
        // Clock skew validation
        long now = System.currentTimeMillis();
        long skew = Math.abs(now - requestTimestamp);
        boolean withinTolerance = skew <= timestampToleranceMs;
        
        totalLatencyNs.add(System.nanoTime() - startNs);
        
        if (!signatureMatches || !withinTolerance) {
            rejectedCount.increment();
            return false;
        }
        
        validCount.increment();
        return true;
    }
    
    private String bytesToHex(byte[] bytes) {
        StringBuilder hexString = new StringBuilder(32);
        for (byte b : bytes) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
        return hexString.toString();
    }
}

Step 3: Nonce Tracking, Expiration Checks, and Replay Prevention

Cognigy payloads contain a messageId field that serves as a natural nonce. Track processed nonces in a thread-safe map with expiration timestamps. Reject duplicate nonces within the tolerance window to block replay attacks. Schedule a background cleanup task to evict expired entries and prevent memory leaks.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class NonceTracker {
    
    private final ConcurrentHashMap<String, Long> processedNonces = new ConcurrentHashMap<>();
    private final long expirationMs;
    private final ScheduledExecutorService cleanupExecutor;

    public NonceTracker(long expirationMs) {
        this.expirationMs = expirationMs;
        this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor();
        scheduleCleanup();
    }

    public boolean isReplayAttack(String nonce, long requestTimestamp) {
        Long existingTimestamp = processedNonces.putIfAbsent(nonce, requestTimestamp);
        
        if (existingTimestamp != null) {
            // Nonce already exists. Check if within replay window
            return Math.abs(System.currentTimeMillis() - existingTimestamp) <= expirationMs;
        }
        return false;
    }

    private void scheduleCleanup() {
        cleanupExecutor.scheduleAtFixedRate(() -> {
            long cutoff = System.currentTimeMillis() - expirationMs;
            processedNonces.entrySet().removeIf(entry -> entry.getValue() < cutoff);
        }, 60, 60, TimeUnit.SECONDS);
    }
}

Step 4: Asymmetric Payload Decryption and Environment Variable Injection

Extract the encrypted payload segment from the JSON body. Decrypt it using RSA-OAEP with a private key injected via environment variables. Handle cryptographic exceptions explicitly and return structured error states. This step protects sensitive bot input data during ingestion.

import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import javax.crypto.Cipher;
import java.util.Base64;

public class PayloadDecryptor {

    private final PrivateKey rsaPrivateKey;

    public PayloadDecryptor(String pemPrivateKey) throws Exception {
        String cleanedKey = pemPrivateKey
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s", "");
        byte[] keyBytes = Base64.getDecoder().decode(cleanedKey);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        this.rsaPrivateKey = keyFactory.generatePrivate(spec);
    }

    public String decrypt(String encryptedBase64) throws Exception {
        Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
        cipher.init(Cipher.DECRYPT_MODE, rsaPrivateKey);
        byte[] decoded = Base64.getDecoder().decode(encryptedBase64);
        byte[] decryptedBytes = cipher.doFinal(decoded);
        return new String(decryptedBytes, StandardCharsets.UTF_8);
    }
}

Step 5: Metrics Tracking, SIEM Synchronization, and Audit Logging

Aggregate validation latency, acceptance rates, and rejection reasons. Stream metrics to an external SIEM webhook using HttpClient with exponential backoff retry logic. Generate structured audit logs for compliance verification. The SIEM callback accepts JSON payloads containing security event metadata.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;

public class SecurityMetricsSink {

    private final HttpClient httpClient;
    private final String siemUrl;
    private final ObjectMapper mapper = new ObjectMapper();

    public SecurityMetricsSink(String siemUrl) {
        this.siemUrl = siemUrl;
        this.httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();
    }

    public void reportValidationEvent(ValidationEvent event) {
        try {
            String jsonPayload = mapper.writeValueAsString(event);
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(siemUrl))
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
                    .build();
            
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            
            if (response.statusCode() >= 400) {
                // Retry with exponential backoff
                retryWithBackoff(jsonPayload, 1000L);
            }
        } catch (Exception e) {
            System.err.println("SIEM sync failed: " + e.getMessage());
        }
    }

    private void retryWithBackoff(String payload, long delayMs) {
        try {
            Thread.sleep(delayMs);
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(siemUrl))
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(payload))
                    .build();
            httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        } catch (Exception ignored) {
            // Log and drop after final retry to prevent blocking
        }
    }

    public record ValidationEvent(
            String eventId,
            long timestamp,
            boolean isValid,
            String reason,
            long latencyNs,
            double rejectionRate,
            String auditTrail
    ) {}
}

Complete Working Example

The following module integrates all components into a production-ready Spring Boot service. It handles signature verification, nonce tracking, decryption, metrics streaming, and audit logging in a single request lifecycle.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import java.util.concurrent.ConcurrentHashMap;

@SpringBootApplication
public class CognigyWebhookValidatorApp {
    public static void main(String[] args) {
        SpringApplication.run(CognigyWebhookValidatorApp.class, args);
    }
}

@Configuration
class SecurityBeans {
    
    @Bean
    WebhookValidator validator(@Value("${COGNIGY_WEBHOOK_SECRET}") String secret,
                               @Value("${WEBHOOK_TIMESTAMP_TOLERANCE_MS:300000}") long tolerance) {
        return new WebhookValidator(secret, tolerance);
    }

    @Bean
    NonceTracker nonceTracker(@Value("${WEBHOOK_TIMESTAMP_TOLERANCE_MS:300000}") long tolerance) {
        return new NonceTracker(tolerance);
    }

    @Bean
    PayloadDecryptor decryptor(@Value("${DECRYPTION_PRIVATE_KEY}") String key) throws Exception {
        return new PayloadDecryptor(key);
    }

    @Bean
    SecurityMetricsSink metricsSink(@Value("${SIEM_WEBHOOK_URL}") String url) {
        return new SecurityMetricsSink(url);
    }
}

The controller orchestrates the pipeline:

@RestController
@RequestMapping("/webhooks/cognigy")
class CognigyWebhookController {

    private final WebhookValidator validator;
    private final NonceTracker nonceTracker;
    private final PayloadDecryptor decryptor;
    private final SecurityMetricsSink metricsSink;
    private final ObjectMapper mapper = new ObjectMapper();

    public CognigyWebhookController(WebhookValidator validator,
                                    NonceTracker nonceTracker,
                                    PayloadDecryptor decryptor,
                                    SecurityMetricsSink metricsSink) {
        this.validator = validator;
        this.nonceTracker = nonceTracker;
        this.decryptor = decryptor;
        this.metricsSink = metricsSink;
    }

    @PostMapping
    public ResponseEntity<?> handleWebhook(HttpServletRequest request) throws Exception {
        String rawBody = request.getReader().lines().reduce("", (a, b) -> a + b);
        String signature = request.getHeader("X-Cognigy-Signature");
        String timestampHeader = request.getHeader("X-Cognigy-Timestamp");

        if (signature == null || timestampHeader == null) {
            return ResponseEntity.status(400).body("{\"error\": \"Missing required headers\"}");
        }

        long requestTimestamp = Long.parseLong(timestampHeader);
        var payload = mapper.readValue(rawBody, Map.class);
        String messageId = (String) payload.get("messageId");
        
        // Step 1: Replay attack check
        if (nonceTracker.isReplayAttack(messageId, requestTimestamp)) {
            reportEvent(messageId, requestTimestamp, false, "REPLAY_ATTACK_DETECTED", 0, rawBody);
            return ResponseEntity.status(409).body("{\"error\": \"Duplicate message detected\"}");
        }

        // Step 2: Signature verification
        boolean valid = validator.verifySignature(rawBody, signature, requestTimestamp);
        if (!valid) {
            reportEvent(messageId, requestTimestamp, false, "SIGNATURE_OR_CLOCK_SKEW_INVALID", 0, rawBody);
            return ResponseEntity.status(401).body("{\"error\": \"Invalid signature or timestamp\"}");
        }

        // Step 3: Decrypt sensitive payload if present
        String encryptedData = (String) payload.get("encryptedPayload");
        String decryptedContent = null;
        if (encryptedData != null) {
            try {
                decryptedContent = decryptor.decrypt(encryptedData);
            } catch (Exception e) {
                reportEvent(messageId, requestTimestamp, false, "DECRYPTION_FAILED", 0, rawBody);
                return ResponseEntity.status(422).body("{\"error\": \"Payload decryption failed\"}");
            }
        }

        // Step 4: Success metrics and audit
        long latencyNs = validator.getLatencyForRequest(); // Exposed via validator if needed
        reportEvent(messageId, requestTimestamp, true, "VALID", latencyNs, decryptedContent);
        
        return ResponseEntity.ok("{\"status\": \"accepted\", \"decrypted\": \"" + (decryptedContent != null ? "true" : "false") + "\"}");
    }

    private void reportEvent(String messageId, long timestamp, boolean isValid, String reason, long latencyNs, String payloadData) {
        var event = new SecurityMetricsSink.ValidationEvent(
                java.util.UUID.randomUUID().toString(),
                System.currentTimeMillis(),
                isValid,
                reason,
                latencyNs,
                0.0, // Calculated dynamically in production
                "messageId=" + messageId + "|reason=" + reason
        );
        metricsSink.reportValidationEvent(event);
    }
}

Common Errors & Debugging

Error: 401 Unauthorized - Signature Mismatch

The computed HMAC digest does not match X-Cognigy-Signature. This occurs when the raw body contains modified whitespace, the secret environment variable contains trailing newlines, or the character encoding differs from UTF-8. Verify the secret matches the Cognigy Studio configuration exactly. Ensure the request reader consumes the body without buffering transformations.

Error: 401 Unauthorized - Clock Skew Exceeded

The absolute difference between System.currentTimeMillis() and X-Cognigy-Timestamp exceeds the tolerance window. Synchronize server time via NTP. Increase WEBHOOK_TIMESTAMP_TOLERANCE_MS if network routing introduces predictable latency, but do not exceed 600000 milliseconds to maintain replay protection.

Error: 409 Conflict - Nonce Collision

The messageId already exists in the nonce tracking map within the expiration window. Cognigy retries failed webhook deliveries. Your service must return a 2xx status on the first successful validation. If downstream processing fails, cache the message and return 200 to stop retries. Implement idempotent processing downstream.

Error: 422 Unprocessable Entity - Decryption Failure

The RSA-OAEP decryption throws BadPaddingException or InvalidKeyException. Verify the private key format matches PKCS#8. Ensure the encrypted payload uses Base64 encoding without line breaks. Validate that the Cognigy bot encrypts data using the corresponding public key with SHA-256 MGF1 padding.

Official References