Kafka

kafka-101 in the context of springboot

kafka-101 in the context of springboot

Navdeep SinghSeptember 13, 202612 min read
Text size:M

Kafka with Spring Boot: Core Concepts, Configurations, and Consumers

Apache Kafka has become the backbone of event-driven architectures in modern microservices. While Kafka itself is a powerful distributed streaming platform, Spring Kafka wraps it with idiomatic Spring abstractions that dramatically reduce boilerplate while still giving you full control over every configuration knob. This guide walks through the core mental model, production-ready Java configuration, serialization philosophy, multi-consumer group patterns, and a critical security concern you must address before going to production.


1. Core Kafka Concepts

Before writing a single line of Java, it pays to have a solid mental model of how Kafka actually works. These five primitives underpin everything else.

Broker

A Kafka broker is simply a server (JVM process) that stores and serves messages. In production you run a cluster of brokers — typically three or more — for fault tolerance and horizontal scalability. Brokers coordinate through a consensus layer; in Kafka 3.x+ that is the built-in KRaft (Kafka Raft) mode, replacing the older ZooKeeper dependency.

Cluster
├── Broker 1  (leader for partitions P0, P3)
├── Broker 2  (leader for partitions P1, P4)
└── Broker 3  (leader for partitions P2, follower for P0, P1 ...)

Every broker knows about every other broker, so any broker can redirect a producer or consumer to the correct leader for a given partition.

Topic & Partition

A topic is a logical feed of messages — think of it as a named, append-only log. Each topic is split into one or more partitions, which are the actual units of parallelism and ordering in Kafka.

Key properties of partitions:

  • Messages inside a single partition are totally ordered by their offset.
  • Ordering is not guaranteed across partitions.
  • Each partition is replicated across a configurable number of brokers (the replication factor).
  • The number of partitions is the ceiling on the maximum number of consumers that can work in parallel within a single consumer group.
Topic: order-events  (3 partitions, replication-factor 3)

Partition 0: [offset 0] [offset 1] [offset 2] ...
Partition 1: [offset 0] [offset 1] ...
Partition 2: [offset 0] [offset 1] [offset 2] [offset 3] ...

Choosing partition count is an architectural decision: too few and you cap throughput; too many and you increase coordination overhead.

Producer

A producer publishes records to topics. Each record has:

  • A key (optional but highly recommended) — used for partition routing
  • A value — the actual payload
  • An optional set of headers — lightweight metadata
  • A timestamp

When a key is present, Kafka hashes it with a DefaultPartitioner (using murmur2) to deterministically route all records sharing the same key to the same partition. This is the foundation of ordering guarantees: if order matters (e.g., all events for orderId=42 must be processed in sequence), use the order ID as the message key.

Producers are acks-configurable:

acks setting Meaning Trade-off
0 Fire-and-forget Highest throughput, possible data loss
1 Leader acknowledgement Balanced default
all / -1 All in-sync replicas must ack Strongest durability guarantee

Consumer

A consumer reads records from one or more topic partitions by polling Kafka at regular intervals. Consumers track progress using offsets — a simple integer pointing to the next record to be read within a partition. Offsets can be committed automatically or manually (recommended for at-least-once or exactly-once semantics).

Consumer Group

A consumer group is a logical grouping of consumer instances that collectively consume a topic. Kafka's partition-assignment protocol guarantees that each partition is consumed by exactly one consumer instance within a given group at any point in time.

Topic: order-events (3 partitions)

Consumer Group A (order-service):
  Consumer A-1 → Partition 0
  Consumer A-2 → Partition 1
  Consumer A-3 → Partition 2

Consumer Group B (audit-service):
  Consumer B-1 → Partition 0, Partition 1
  Consumer B-2 → Partition 2

This design enables both load-balanced processing (within a group) and broadcast-style consumption (across groups) simultaneously — a pattern we will explore in depth later.


2. Producer & Consumer Configuration in Spring Boot

Spring Kafka maps directly to Kafka's native client API, but lets you express configuration as Spring beans. Below are complete, production-ready configuration classes with annotations explaining every non-obvious decision.

Maven Dependencies

<dependencies>
    <!-- Spring Kafka -->
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
 
    <!-- Jackson for JSON serialization -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

Application Properties (Base Configuration)

# application.yml
spring:
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
    # Producer and consumer configs are defined programmatically below,
    # but bootstrap-servers is shared and can be injected via @Value.

KafkaProducerConfig

package com.example.kafka.config;
 
import com.example.kafka.dto.OrderEvent;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.support.serializer.JsonSerializer;
 
import java.util.HashMap;
import java.util.Map;
 
/**
 * Production-ready Kafka Producer configuration.
 *
 * Design decisions:
 * - StringSerializer for keys: deterministic partition routing via murmur2 hash.
 * - JsonSerializer for values: supports rich domain object payloads.
 * - acks=all + min.insync.replicas (set on broker) = strongest durability guarantee.
 * - Idempotent producer prevents duplicate messages on retries.
 * - Compression reduces network I/O and broker storage at the cost of CPU.
 */
@Configuration
public class KafkaProducerConfig {
 
    @Value("${spring.kafka.bootstrap-servers}")
    private String bootstrapServers;
 
    /**
     * Builds the raw producer configuration map.
     * Separating this into its own method makes it easy to override in tests
     * via @TestConfiguration + EmbeddedKafka.
     */
    @Bean
    public Map<String, Object> producerConfigs() {
        Map<String, Object> props = new HashMap<>();
 
        // ----- Connection -----
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
 
        // ----- Serialization -----
        // String key → predictable, human-readable, hashable for partition routing
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        // JSON value → rich domain objects without a schema registry dependency
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
        // Embed the Java type header so consumers can deserialize without guessing
        props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, true);
 
        // ----- Durability -----
        // all = leader + every in-sync replica must acknowledge before ack is returned
        props.put(ProducerConfig.ACKS_CONFIG, "all");
        // Idempotent producer: broker deduplicates retries using a sequence number
        // This also implicitly sets max.in.flight.requests.per.connection = 5
        // and retries = Integer.MAX_VALUE
        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
 
        // ----- Throughput & Latency -----
        // Batch up to 64 KB before sending (reduces per-record overhead)
        props.put(ProducerConfig.BATCH_SIZE_CONFIG, 65536);
        // Wait up to 10 ms to fill a batch (trading latency for throughput)
        props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
        // LZ4 compression: best balance of speed vs. compression ratio for most workloads
        props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
        // Buffer size before backpressure is applied to the calling thread
        props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432); // 32 MB
 
        // ----- Retry / Timeout -----
        // How long send() will block before throwing a TimeoutException
        props.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 5000);
        // Total time a delivery may be attempted (including retries)
        props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120000);
        // Backoff between individual retry attempts
        props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 500);
 
        return props;
    }
 
    /**
     * ProducerFactory is the Spring abstraction over Kafka's KafkaProducer.
     * DefaultKafkaProducerFactory creates a single shared producer by default,
     * which is thread-safe and the correct default for most applications.
     */
    @Bean
    public ProducerFactory<String, Object> producerFactory() {
        return new DefaultKafkaProducerFactory<>(producerConfigs());
    }
 
    /**
     * KafkaTemplate is the primary send API in Spring Kafka.
     * It wraps ProducerFactory and provides synchronous/asynchronous send methods,
     * transaction support, and result callback wiring.
     */
    @Bean
    public KafkaTemplate<String, Object> kafkaTemplate() {
        KafkaTemplate<String, Object> template = new KafkaTemplate<>(producerFactory());
        // Globally observe send results — log failures at WARN level
        template.setObservationEnabled(true);
        return template;
    }
}

KafkaConsumerConfig

package com.example.kafka.config;
 
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;
import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer;
import org.springframework.kafka.support.serializer.JsonDeserializer;
 
import java.util.HashMap;
import java.util.Map;
 
/**
 * Production-ready Kafka Consumer configuration.
 *
 * Design decisions:
 * - ErrorHandlingDeserializer wraps JsonDeserializer so a single malformed
 *   message does not poison the entire partition.
 * - Manual offset commit (MANUAL_IMMEDIATE) gives the application full control
 *   over at-least-once delivery semantics.
 * - DefaultErrorHandler with exponential backoff retries transient failures
 *   (e.g., database unavailable) before sending to a Dead Letter Topic (DLT).
 * - trusted.packages whitelist prevents Remote Code Execution during
 *   type-header-guided deserialization (see Section 5).
 */
@Configuration
public class KafkaConsumerConfig {
 
    @Value("${spring.kafka.bootstrap-servers}")
    private String bootstrapServers;
 
    /**
     * The consumer group ID should identify the *service*, not the instance.
     * All instances of the same microservice share this ID so Kafka distributes
     * partitions across them rather than duplicating every message to each instance.
     */
    @Value("${spring.kafka.consumer.group-id:order-service-group}")
    private String groupId;
 
    @Bean
    public Map<String, Object> consumerConfigs() {
        Map<String, Object> props = new HashMap<>();
 
        // ----- Connection -----
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
 
        // ----- Serialization -----
        // ErrorHandlingDeserializer: on deserialization failure, the record is
        // forwarded to the error handler instead of throwing an unrecoverable exception
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                ErrorHandlingDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                ErrorHandlingDeserializer.class);
        // Delegate deserializers for the wrapped types
        props.put(ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS,
                StringDeserializer.class);
        props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS,
                JsonDeserializer.class);
 
        // ----- Security: Trusted Packages -----
        // ONLY deserialize types from these packages (see Section 5 for rationale).
        // NEVER use "*" in production.
        props.put(JsonDeserializer.TRUSTED_PACKAGES,
                "com.example.kafka.dto,com.example.shared.events");
        // Optionally map incoming type headers to local class names (useful when
        // producer and consumer live in different Maven modules / class names differ)
        props.put(JsonDeserializer.TYPE_MAPPINGS,
                "orderEvent:com.example.kafka.dto.OrderEvent," +
                "paymentEvent:com.example.kafka.dto.PaymentEvent");
 
        // ----- Offset Management -----
        // "earliest" on first join (no committed offset) — process from the beginning
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        // Disable auto-commit: we commit manually after successful processing
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
 
        // ----- Fetch Tuning -----
        // Minimum data fetched per poll — reduces round-trips under low throughput
        props.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1);
        // Maximum wait time if fetch.min.bytes is not met
        props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500);
        // Maximum records returned per poll() call — tune based on processing time
        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 50);
        // Must process MAX_POLL_RECORDS within this window or the coordinator
        // considers the consumer dead and triggers a rebalance
        props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000); // 5 min
 
        return props;
    }
 
    @Bean
    public ConsumerFactory<String, Object> consumerFactory() {
        return new DefaultKafkaConsumerFactory<>(consumerConfigs());
    }
 
    /**
     * DefaultErrorHandler replaces the deprecated SeekToCurrentErrorHandler.
     *
     * Backoff strategy:
     *   Attempt 1 → immediate
     *   Attempt 2 → 1s
     *   Attempt 3 → 2s
     *   Attempt 4 → 4s
     *   Attempt 5 → 8s   ← maxRetries
     *   → publish to Dead Letter Topic (DLT)
     *
     * The DLT topic name follows the convention: <original-topic>.DLT
     * Requires a DeadLetterPublishingRecoverer bean (wired separately or inline).
     */
    @Bean
    public DefaultErrorHandler kafkaErrorHandler() {
        // Exponential backoff: initial interval 1s, multiplier 2x, max 5 retries
        ExponentialBackOffWithMaxRetries backOff =
                new ExponentialBackOffWithMaxRetries(5);
        backOff.setInitialInterval(1_000L);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(30_000L); // cap at 30 seconds
 
        DefaultErrorHandler errorHandler = new DefaultErrorHandler(backOff);
 
        // Do NOT retry on these exceptions — they indicate a programming error,
        // not a transient infrastructure failure
        errorHandler.addNotRetryableExceptions(
                IllegalArgumentException.class,
                NullPointerException.class,
                ClassCastException.class
        );
 
        // Log each retry attempt
        errorHandler.setRetryListeners((record, ex, deliveryAttempt) ->
                System.err.printf("[KafkaRetry] topic=%s partition=%d offset=%d " +
                        "attempt=%d error=%s%n",
                        record.topic(), record.partition(), record.offset(),
                        deliveryAttempt, ex.getMessage())
        );
 
        return errorHandler;
    }
 
    /**
     * ConcurrentKafkaListenerContainerFactory creates the listener containers
     * that back @KafkaListener methods.
     *
     * concurrency = number of threads per listener, each owning one or more partitions.
     * Set this to the number of partitions (or a divisor of it) for optimal assignment.
     */
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, Object>
            kafkaListenerContainerFactory() {
 
        ConcurrentKafkaListenerContainerFactory<String, Object> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
 
        factory.setConsumerFactory(consumerFactory());
        factory.setCommonErrorHandler(kafkaErrorHandler());
 
        // Concurrency: 3 threads ↔ 3 partitions (adjust to match your topic)
        factory.setConcurrency(3);
 
        // MANUAL_IMMEDIATE: ack() must be called explicitly in the @KafkaListener
        // method. The offset is committed to Kafka as soon as ack() is called.
        factory.getContainerProperties()
               .setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
 
        // Enable batch listening — receive a List<ConsumerRecord> per poll
        // (comment out for record-by-record processing)
        // factory.setBatchListener(true);
 
        // Micrometer observation support for distributed tracing
        factory.getContainerProperties().setObservationEnabled(true);
 
        return factory;
    }
}

A Listener Using the Above Configuration

package com.example.kafka.consumer;
 
import com.example.kafka.dto.OrderEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
 
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEventConsumer {
 
    private final OrderProcessingService orderProcessingService;
 
    /**
     * groupId is inherited from spring.kafka.consumer.group-id (or the bean default).
     * topics can be a SpEL expression: topics = "${app.kafka.topics.orders}"
     */
    @KafkaListener(
            topics = "${app.kafka.topics.orders:order-events}",
            containerFactory = "kafkaListenerContainerFactory"
    )
    public void handleOrderEvent(
            ConsumerRecord<String, OrderEvent> record,
            Acknowledgment ack) {
 
        log.info("Received order event: key={} partition={} offset={}",
                record.key(), record.partition(), record.offset());
 
        try {
            orderProcessingService.process(record.value());
            // Only commit the offset after successful processing
            ack.acknowledge();
        } catch (Exception e) {
            // Do NOT ack — DefaultErrorHandler will retry according to backoff policy
            log.error("Failed to process order event at offset {}: {}",
                    record.offset(), e.getMessage());
            throw e; // re-throw so the error handler can intercept
        }
    }
}

3. Why String Keys and JSON Values?

This combination is not arbitrary — it reflects a deliberate trade-off between operational simplicity, partition semantics, and payload expressiveness.

String Keys: Deterministic Partition Routing

Kafka's default partitioner applies a murmur2 hash to the serialized bytes of the key to determine which partition receives a given record:

partition = murmur2(keyBytes) % numPartitions

Because the hash operates on raw bytes, the serialization format directly affects which partition a record lands in. String serialization produces stable, predictable bytes: the string "order-42" always serializes to the same UTF-8 byte sequence on any JVM, any platform, any time.

If you were to use a JSON-serialized key (e.g., {"orderId": 42}), you would immediately face:

  • Field ordering non-determinism — Jackson does not guarantee field order without extra configuration, so {"orderId":42} and {"orderId": 42} (note the space) produce different byte sequences and therefore different partition assignments.
  • Schema evolution brittleness — adding a field to the key DTO re-routes all future records to different partitions, silently breaking ordering guarantees for in-flight data.
  • Debugging friction — viewing raw Kafka messages in a console consumer (kafka-console-consumer.sh) renders String keys as human-readable text without any tooling. Binary or JSON keys require a custom deserializer or offline tooling to inspect.

Using a plain String (typically a domain entity ID, a composite key like "region:customerId", or a UUID) sidesteps all of these issues and makes the routing logic transparent and auditable.

JSON Values: Rich, Evolvable Payloads

The value of a Kafka record carries the actual domain event. Here the priorities flip:

  • Richness — domain events naturally map to structured objects with nested fields, arrays, enums, and timestamps. JSON represents this idiomatically without requiring a separate schema registry.
  • Evolvability — Jackson's @JsonIgnoreProperties(ignoreUnknown = true) allows consumers to safely ignore fields they do not yet know about, enabling independent deployment of producers and consumers — a cornerstone of microservice independence.
  • Human readability — engineers can inspect live events with standard CLI tooling or Kafka UI without specialized decoders.
  • Ecosystem compatibility — virtually every language and framework speaks JSON, making cross-team and cross-language integration straightforward.

The trade-off is verbosity: JSON is not the most compact format. For high-throughput systems where storage and network I/O are primary constraints, Avro (with Confluent Schema Registry) or Protobuf offer binary encoding with schema enforcement. However, for the majority of business-domain event streaming use cases, JSON's developer ergonomics outweigh the overhead — especially combined with the lz4 compression configured in KafkaProducerConfig above.


4. Multiple Consumer Groups: Pub/Sub Across Microservices

One of Kafka's most powerful architectural capabilities is the ability for multiple independent services to consume the same topic independently — each maintaining its own offset, its own processing pace, and its own business logic. This is fundamentally different from traditional message queues, where a message is consumed once and deleted.

The Pattern

                         ┌──────────────────────────────────┐
                         │        order-events (topic)       │
                         │  P0 │ P1 │ P2                     │
                         └──────┬──────┬──────┬──────────────┘
                                │      │      │
            ┌───────────────────┘      │      └─────────────────────────┐
            ▼                          ▼                                 ▼
  ┌──────────────────┐      ┌──────────────────┐             ┌─────────────────────┐
  │  order-service   │      │  inventory-svc   │             │   audit-service     │
  │  group-id:       │      │  group-id:        │             │  group-id:          │
  │  order-svc-group │      │  inventory-group  │             │  audit-group        │
  │                  │      │                   │             │                     │
  │  Process orders, │      │  Decrement stock  │             │  Append immutable   │
  │  update DB       │      │  for each order   │             │  audit log          │
  └──────────────────┘      └──────────────────┘             └─────────────────────┘

Each service has its own committed offset per partition. If the audit service falls behind (e.g., due to a downstream outage), it simply lags — it does not block or slow down the order service or inventory service in any way.

When to Use Multiple Consumer Groups

Cross-domain Pub/Sub: Different bounded contexts reacting to the same business event. An order-placed event might trigger fulfillment in the warehouse service, a confirmation email in the notification service, a stock decrement in the inventory service, and a revenue entry in the accounting service — all independently, without any of them knowing about the others.

Separation of operational and analytical concerns: Your order-service processes events with strict latency requirements. A separate analytics-group consumer can consume the same topic at its own pace and write to a data warehouse or time-series store without any risk of impacting the operational path.

Auditing and compliance: Regulatory requirements often demand that every event be written to an immutable audit log by a dedicated service, independent of business processing. Coupling this to the main consumer group would mean an audit failure blocks business processing — clearly unacceptable.

Staged rollouts and shadow mode: During a migration, you can run a new consumer group reading the same topic to validate output before switching traffic over, without touching the existing consumer group.

Configuration for Multiple Groups

Each service defines its own group-id. In Spring Boot with multiple consumers in the same application (less common, but valid for a BFF or aggregator):

// Consumer for operational processing
@KafkaListener(
    topics = "order-events",
    groupId = "order-service-group",
    containerFactory = "kafkaListenerContainerFactory"
)
public void processOrder(ConsumerRecord<String, OrderEvent> record, Acknowledgment ack) {
    // ... business logic
}
 
// Consumer for audit trail — same topic, different group
@KafkaListener(
    topics = "order-events",
    groupId = "audit-service-group",
    containerFactory = "kafkaListenerContainerFactory"
)
public void auditOrder(ConsumerRecord<String, OrderEvent> record, Acknowledgment ack) {
    // ... write to audit log
}

More commonly in a microservices architecture, each of these would live in a completely separate Spring Boot application with its own spring.kafka.consumer.group-id configuration.

A Word on Consumer Group Lag

Monitor consumer_lag (the difference between the latest produced offset and the consumer's committed offset) per group per partition. Tools like Kafka UI, Burrow, or Confluent Control Center provide this out of the box. A growing lag in the audit group is operationally acceptable; a growing lag in the order-processing group is a production incident.


5. Security: spring.json.trusted.packages

This is the most commonly misconfigured Kafka security setting in Spring Boot applications, and getting it wrong exposes your application to Remote Code Execution (RCE).

The Threat: Deserialization Gadget Attacks

When JsonSerializer publishes a message, it can embed a type header — a Java class name — in the Kafka record metadata:

Headers:
  __TypeId__ = com.example.kafka.dto.OrderEvent

On the consumer side, JsonDeserializer reads this header and instantiates that class during deserialization. If an attacker can publish a message to the topic (through a compromised producer, a misconfigured access control list, or an internal threat), they can set __TypeId__ to any class available on the consumer's classpath:

__TypeId__ = org.springframework.context.support.ClassPathXmlApplicationContext

Gadget chains against ObjectMapper (Jackson), combined with carefully crafted JSON payloads, can trigger arbitrary code execution during deserialization — a category of vulnerability well-documented in CVE databases for frameworks including Jackson, XStream, and Java native serialization.

The Defense: spring.json.trusted.packages

# DANGEROUS — allows any class from any package
spring.kafka.consumer.properties.spring.json.trusted.packages=*
 
# CORRECT — whitelist only the packages your application owns
spring.kafka.consumer.properties.spring.json.trusted.packages=\
  com.example.kafka.dto,\
  com.example.shared.events

In Java configuration (as shown in KafkaConsumerConfig):

props.put(JsonDeserializer.TRUSTED_PACKAGES,
        "com.example.kafka.dto,com.example.shared.events");

When a message arrives with a __TypeId__ header pointing to a class outside these packages, JsonDeserializer throws a IllegalStateException with a message like:

The class 'org.springframework.context.support.ClassPathXmlApplicationContext'
is not in the trusted packages: [com.example.kafka.dto, ...]

The ErrorHandlingDeserializer wrapper we configured earlier ensures this poisoned message is routed to the error handler (and ultimately the DLT) rather than crashing the consumer thread.

Additional Type Safety with TYPE_MAPPINGS

Beyond security, type mappings decouple the producer's class name from the consumer's. This is valuable when:

  • Producer and consumer are in different Maven modules with different base packages
  • You are renaming or refactoring DTOs as part of a migration
  • You want to map a single Kafka type to different classes in different consumers
// Producer side: publishes with header __TypeId__ = "orderEvent"
props.put(JsonSerializer.TYPE_MAPPINGS,
        "orderEvent:com.example.producer.events.OrderEvent");
 
// Consumer side: maps "orderEvent" to its own local DTO
props.put(JsonDeserializer.TYPE_MAPPINGS,
        "orderEvent:com.example.consumer.dto.OrderEvent");

This completely avoids embedding fully qualified class names in message headers, making the system more resilient to refactoring and reducing the attack surface further.

Security Checklist

Before going to production, verify the following:

  • trusted.packages is set to a specific, minimal list of packages — never *.
  • Kafka ACLs are configured so only authorized producers can write to sensitive topics.
  • TLS (SSL) is enabled for broker-to-client communication to prevent header tampering in transit.
  • SASL authentication (SCRAM-SHA-512 or OAuth 2.0) is enabled on the broker.
  • The ErrorHandlingDeserializer wraps your JsonDeserializer so type violations do not crash the consumer.
  • Dead Letter Topics are monitored and alerts fire when messages land there.

Summary

Concern Recommendation
Key serializer StringSerializer — stable bytes, human-readable, deterministic partition routing
Value serializer JsonSerializer with ADD_TYPE_INFO_HEADERS=true
Value deserializer ErrorHandlingDeserializer wrapping JsonDeserializer
Offset management Manual commit (MANUAL_IMMEDIATE) for at-least-once semantics
Error handling DefaultErrorHandler with exponential backoff + DLT
Trusted packages Explicit whitelist — never *
Multiple consumers Separate group-id per consuming service
Durability acks=all + idempotent producer

Kafka's power lies in its simplicity at the storage layer — an ordered, replicated, partitioned log — combined with immense flexibility at the consumer layer. Spring Kafka maps that flexibility into familiar Spring idioms without hiding the underlying semantics. Understanding those semantics — partition routing, consumer group rebalancing, offset commit semantics, and deserialization security — is what separates a fragile Kafka integration from one that runs reliably at production scale.