Skip to content

Security & Production

Kafka security is three real, separately-provable layers — authorization (ACLs, which do nothing at all until you turn on an authorizer), authentication (SASL or mTLS, which decides who a principal even is), and encryption (TLS) — and skipping the first step, as a lot of tutorials do, produces a command that looks like it should work and instead throws immediately.

Proof: kafka-acls.sh with no authorizer configured

Section titled “Proof: kafka-acls.sh with no authorizer configured”

Kafka ships with no authorizer by default. Running the exact command a lot of tutorials open with, against a broker that has not enabled one:

$ kafka-acls.sh --bootstrap-server localhost:9092 --list
Error while executing ACL command: org.apache.kafka.common.errors.SecurityDisabledException: No Authorizer is configured on the broker
java.util.concurrent.ExecutionException: org.apache.kafka.common.errors.SecurityDisabledException: No Authorizer is configured on the broker

This is not a permissions error — it is kafka-acls.sh telling you, correctly, that there is no ACL system running at all to query. The fix is a broker config, set before the broker starts:

config/security-and-production/authorizer.properties
# section: broker
authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer

StandardAuthorizer is the KRaft-native authorizer (the ZooKeeper-era AclAuthorizer is gone along with ZooKeeper). Pair it with super.users (documented under Kafka’s authorization/ACL configuration, not the general broker-configs table above) — a comma-separated principalType:name list that bypasses every ACL check. Every real cluster needs at least one super user, or a locked-down authorizer can lock out its own admins:

super.users=User:ANONYMOUS

Proof: authorizer on, no ACL yet — a real denied produce

Section titled “Proof: authorizer on, no ACL yet — a real denied produce”

With the authorizer enabled and a SASL/SCRAM listener added (full setup below), a client authenticated as order-service — a real principal, not a super user — tries to produce to a topic it has never been granted access to:

$ kafka-console-producer.sh --bootstrap-server localhost:39094 --topic secure-orders \
--command-config order-service-client.properties
[...] WARN [Producer clientId=console-producer] The metadata response from the cluster reported a recoverable issue with correlation id 1 : {secure-orders=TOPIC_AUTHORIZATION_FAILED} (org.apache.kafka.clients.NetworkClient)
[...] ERROR [Producer clientId=console-producer] Topic authorization failed for topics [secure-orders] (org.apache.kafka.clients.Metadata)
org.apache.kafka.common.errors.TopicAuthorizationException: Not authorized to access topics: [secure-orders]

Real, verbatim TopicAuthorizationException — authentication succeeded (SCRAM verified the password, proven further down), but authorization did not. kafka-acls.sh --add is the fix:

$ kafka-acls.sh --bootstrap-server localhost:9092 \
--add --allow-principal User:order-service \
--operation Write --topic secure-orders
Adding ACLs for resource `ResourcePattern(resourceType=TOPIC, name=secure-orders, patternType=LITERAL)`:
(principal=User:order-service, host=*, operation=WRITE, permissionType=ALLOW)
$ kafka-console-producer.sh --bootstrap-server localhost:39094 --topic secure-orders \
--command-config order-service-client.properties
(exits 0, no output -- the record went through)
sequenceDiagram
  participant C as order-service (authenticated, not super)
  participant B as Broker
  C->>B: Produce to secure-orders (no ACL granted)
  B-->>C: TOPIC_AUTHORIZATION_FAILED -> real TopicAuthorizationException
  Note over B: kafka-acls.sh --add --allow-principal User:order-service --operation Write --topic secure-orders
  C->>B: Produce to secure-orders (retry, same credentials)
  B-->>C: accepted -- exit 0
Authorization proven wrong, then right, with the same principal

Two real Docker-image bugs found setting this up

Section titled “Two real Docker-image bugs found setting this up”

Building this lesson’s own SASL/SCRAM broker hit two genuine, undocumented-until-you-hit-them failures in the official image — both worth knowing, since either one produces a startup crash that has nothing to do with your Kafka config being wrong.

Two ways to provision SCRAM credentials in KRaft (no ZooKeeper, so no separate --zookeeper flag exists for this either): at format time, or dynamically against a running cluster. The format-time flag, confirmed against the real tool’s own help text:

scripts/security-and-production/storage-format-help.sh
/opt/kafka/bin/kafka-storage.sh format --help
--add-scram ADD_SCRAM, -S ADD_SCRAM
A SCRAM_CREDENTIAL to add to the __cluster_metadata log e.g.
'SCRAM-SHA-256=[name=alice,password=alice-secret]'
'SCRAM-SHA-512=[name=alice,iterations=8192,salt="N3E=",saltedpassword="YCE="]'

This lesson used the second path — adding a user to an already-running cluster, which is what most real operational workflows actually do (rotating or adding a credential without reformatting anything):

scripts/security-and-production/scram-user-lifecycle.sh
# Self-contained: creates its own SCRAM user, proves it, then deletes it.
/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--alter --entity-type users --entity-name lesson-scram-demo \
--add-config 'SCRAM-SHA-256=[password=demo-secret-pw]'
/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--describe --entity-type users --entity-name lesson-scram-demo
/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--alter --entity-type users --entity-name lesson-scram-demo \
--delete-config 'SCRAM-SHA-256'
Completed updating config for user lesson-scram-demo.
SCRAM credential configs for user-principal 'lesson-scram-demo' are SCRAM-SHA-256=iterations=4096
Completed updating config for user lesson-scram-demo.

The credential itself — never the plaintext password — is what gets stored: --describe shows iterations=4096, never demo-secret-pw. The listener needs to actually offer SCRAM too:

config/security-and-production/sasl-listener.properties
# section: broker
sasl.enabled.mechanisms=SCRAM-SHA-256
security.inter.broker.protocol=PLAINTEXT

Proof: a real client authenticates — and a real one fails to

Section titled “Proof: a real client authenticates — and a real one fails to”

A genuine KafkaJS producer, run from the host against the SASL_PLAINTEXT listener, with the correct credentials:

const kafka = new Kafka({
clientId: 'security-lesson-probe',
brokers: ['localhost:39094'],
sasl: { mechanism: 'scram-sha-256', username: 'order-service', password: 'order-secret-pw' },
});
const producer = kafka.producer();
await producer.connect();
await producer.send({ topic: 'secure-orders', messages: [{ value: 'hello from KafkaJS over SASL_PLAINTEXT' }] });
SASL/SCRAM connect OK as order-service
send OK

The exact same client, wrong password only:

{"level":"ERROR", ..., "message":"[SaslAuthenticator-SCRAM-SHA-256] SASL SCRAM SHA256 authentication failed: Authentication failed during authentication due to invalid credentials with SASL mechanism SCRAM-SHA-256", ...}
connect FAILED: KafkaJSSASLAuthenticationError - SASL SCRAM SHA256 authentication failed: Authentication failed during authentication due to invalid credentials with SASL mechanism SCRAM-SHA-256

And the Java-tooling side of the identical mistake, for comparison — same broker, same wrong password, via kafka-console-producer.sh instead:

[...] ERROR [Producer clientId=console-producer] Connection to node -1 (localhost/127.0.0.1:39094) failed authentication due to: Authentication failed during authentication due to invalid credentials with SASL mechanism SCRAM-SHA-256 (org.apache.kafka.clients.NetworkClient)
org.apache.kafka.common.errors.SaslAuthenticationException: Authentication failed during authentication due to invalid credentials with SASL mechanism SCRAM-SHA-256

Both clients hit the identical broker-side check and get the identical human-readable reason — only the exception class name differs by client (KafkaJSSASLAuthenticationError vs. SaslAuthenticationException), because SCRAM’s challenge-response happens on the wire, not inside either client library.

This lesson does not generate certificates end to end — the broker config keys are the load-bearing part, and they are documented, not folklore. Confirmed against the official broker config reference (kafka.apache.org/43/configuration/, broker section, fetched for this course):

config/security-and-production/tls-broker.properties
# section: broker
listeners=SSL://:9093
ssl.keystore.location=/etc/kafka/secrets/broker.keystore.jks
ssl.keystore.password=changeit
ssl.key.password=changeit
ssl.truststore.location=/etc/kafka/secrets/broker.truststore.jks
ssl.truststore.password=changeit
ssl.client.auth=required
ssl.endpoint.identification.algorithm=https

Each broker needs both a keystore (its own identity — the certificate it presents) and a truststore (who it is willing to trust — normally the CA that signed every broker’s and client’s certificate, in a private cluster). ssl.client.auth=required turns on mutual TLS: the broker also demands and verifies a certificate from the client, which is the mTLS authentication mode mentioned below — TLS alone only proves the broker’s identity to the client, not the other way around. ssl.endpoint.identification.algorithm=https enables hostname verification against the certificate’s SAN, which prevents a trivial man-in-the-middle even when the attacker has a valid certificate, just not one for the hostname being connected to — leaving it unset is a real, common misconfiguration.

Security is one column; durability and observability are the others, covered in Monitoring & Consumer Lag and Performance & Tuning. Before a cluster carries real traffic:

  • authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer, with super.users scoped to real admin principals only — proven above to be required before any ACL means anything.
  • TLS everywhere (ssl.client.auth=required for mTLS, or SASL over SASL_SSL for encrypted authenticated access) — plaintext listeners should not be reachable outside a trusted network boundary.
  • Least-privilege ACLs per principal — proven above with order-service getting exactly Write on exactly secure-orders, nothing broader.
  • Replication factor >= 3 on important topics with min.insync.replicas=2 and producers using acks=all — the durable combination this course’s Acks & Durability lesson proves.
  • Monitoring and alerting on consumer lag, under-replicated partitions, and request latency — the next lesson’s subject.
  • Quotas so one client cannot starve the cluster of bandwidth or request capacity.

🐛 Spot the bug

#!/usr/bin/env bash
# AI comment: "Lock down the orders topic to only the order-service producer"
set -euo pipefail
kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic orders --partitions 3 --replication-factor 3
kafka-acls.sh --bootstrap-server localhost:9092 \
--add --allow-principal User:order-service \
--operation Write --topic orders
Show the answer

This script is a fully plausible “secure the topic” runbook — create it, then grant exactly the one principal that should write to it, nothing broader. It will very likely fail on a real cluster with the same SecurityDisabledException proven at the top of this lesson: nothing in this script (or anywhere upstream of it, if this is the first security-related command a team runs) ever set authorizer.class.name. kafka-acls.sh --add against a broker with no authorizer configured does not silently no-op — it errors out immediately, set -euo pipefail kills the script right there, and the topic that line 6 just created is left with zero ACL enforcement at all, not the locked-down state the comment promises. The fix is not a different kafka-acls.sh flag — it’s a broker-level authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer (plus super.users for whoever runs this script) applied and the broker restarted before any ACL command is ever expected to do something.

Treat Kafka security as three independent yes/no switches, checked in a fixed order for every request: is the connection encrypted (TLS, or not — orthogonal to the other two), does the broker know who is asking (authentication — SASL, mTLS, or “everyone is ANONYMOUS” by default), and is that identity allowed to do this specific thing (authorization — ACLs, which this lesson proved do not even exist as a concept until authorizer.class.name is set). A production cluster needs all three; a lot of tutorials and AI-generated scripts only ever demonstrate the third, silently assuming the first two are handled — which is exactly the gap this lesson’s SpotTheBug walks into.

  • User:ANONYMOUS as a super user on an internal listener — convenient for admin scripting on a listener nothing untrusted can reach (proven as this lesson’s own pattern), and a real security hole the moment that listener is reachable from anywhere it shouldn’t be.
  • SASL/SCRAM vs. mTLS — SCRAM is self-contained (credentials live in the cluster’s own metadata log, proven above, no external CA needed) and easy to rotate per-user; mTLS ties identity to a certificate, which centralizes trust in a CA but avoids shipping passwords anywhere, including into JAAS config strings.
  • Dynamic SCRAM user provisioning (kafka-configs.sh --alter) vs. format-time (kafka-storage.sh format --add-scram) — dynamic is what real operations use (add/rotate a user without touching every broker’s storage), proven above end to end; format-time is really only for bootstrapping the very first admin credential before any broker has ever started.
  • ssl.endpoint.identification.algorithm=https on vs. off — on (the safer default) rejects a certificate that is valid but for the wrong hostname; off accepts it, which is occasionally needed for internal service-mesh setups with non-standard naming but reopens exactly the man-in-the-middle gap hostname verification exists to close.
  • “kafka-acls.sh --add works on any broker; you only need an authorizer if you want ACLs to be enforced.” No — proven above: without authorizer.class.name set, kafka-acls.sh cannot even list ACLs, real SecurityDisabledException, not a soft no-op.
  • “A TopicAuthorizationException means the client’s credentials are wrong.” No — proven above: order-service authenticated successfully (SCRAM verified) and was still denied, because authentication and authorization are two separate checks; the wrong-password case, also proven above, produces a completely different exception (SaslAuthenticationException / KafkaJSSASLAuthenticationError) at a different point in the connection.
  • “SASL/SCRAM needs ZooKeeper or a separate credential store.” No — proven above: kafka-configs.sh --alter --entity-type users writes the credential straight into the KRaft __cluster_metadata log on a running cluster, no ZooKeeper, no external store.
  • “TLS and authentication are the same thing — enabling SSL:// means clients are authenticated.” No — proven in the config above: plain SSL:// only proves the broker’s identity to the client; a client presents no identity at all unless ssl.client.auth=required (mTLS) or a SASL mechanism is layered on top via SASL_SSL.
Proven above, what does kafka-acls.sh --list actually do against a broker with no authorizer.class.name configured?
Proven above, what happened when a principal (order-service) authenticated successfully via SASL/SCRAM but had no ACL granted on the topic it tried to produce to?
What two real, undocumented-until-you-hit-them bugs did this lesson find while enabling a SASL_PLAINTEXT listener on the official apache/kafka image?
Proven above with kafka-configs.sh --describe --entity-type users, what does Kafka actually store for a SCRAM credential?
Proven above, what was the real error text when a KafkaJS producer connected with the correct username but the wrong SCRAM password?
What does ssl.client.auth=required actually change, compared to a plain SSL:// listener without it?