Kafka — MSK IAM
Round-trips a single Ping message against an IAM-enabled Amazon MSK cluster, using SASL_SSL + OAUTHBEARER with auto-refreshed presigned tokens. Run this against a real MSK cluster you have access to — it's the canonical end-to-end check for the kafka-msk-iam feature.
Prerequisites
- A running MSK cluster with IAM authentication enabled
- AWS credentials available via the standard provider chain (env vars, shared credentials file, EC2 instance metadata, EKS pod identity / IRSA, or SSO)
- The caller's principal must have
kafka-cluster:Connecton the cluster (pluskafka-cluster:WriteDataandReadDataon the topic for the publish/consume round-trip) - Cargo features:
kafka-msk-iam(implieskafka-ssl)
Run
MSK_BROKERS=b-1.example.kafka.us-east-1.amazonaws.com:9098,b-2.example.kafka.us-east-1.amazonaws.com:9098 \
AWS_REGION=us-east-1 \
cargo run --example kafka_msk_iam --features kafka-msk-iamFor a non-default named profile, set AWS_PROFILE alongside the rest — the AWS SDK picks it up via the standard provider chain. The example uses KafkaSasl::msk_iam(region), which is the no-profile form. To pin a specific profile in code, swap in KafkaSasl::msk_iam_with_profile(region, profile).
Expected output
Using topic: shove-msk-iam-example-7a3f9c4e8b1d4e2a9c6f5b8d3e1a4f7b
Connecting to MSK cluster at b-1.example.kafka.us-east-1.amazonaws.com:9098,... (region=us-east-1) …
Connected.
Published ping: id=4f2a8b3c-1d5e-4f6a-9b8c-2d7e3a5f8b1c
Received ping: id=4f2a8b3c-1d5e-4f6a-9b8c-2d7e3a5f8b1c
Done.Source
//! MSK IAM Kafka publish/consume example.
//!
//! Connects to an IAM-enabled Amazon MSK cluster and round-trips a single
//! message to verify end-to-end connectivity.
//!
//! Required environment variables:
//!
//! - `MSK_BROKERS` — comma-separated bootstrap broker string
//! (e.g. `b-1.example.kafka.us-east-1.amazonaws.com:9198`)
//! - `AWS_REGION` — AWS region the cluster lives in (e.g. `us-east-1`)
//!
//! Run:
//!
//! MSK_BROKERS=... AWS_REGION=us-east-1 \
//! cargo run -q --example kafka_msk_iam --features kafka-msk-iam
use std::sync::OnceLock;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use shove::kafka::{KafkaConfig, KafkaConsumerGroupConfig, KafkaSasl, KafkaTls};
use shove::{
Broker, ConsumerGroupConfig, JsonCodec, Kafka, MessageHandler, MessageMetadata, Outcome, Topic,
TopologyBuilder,
};
use uuid::Uuid;
// --------------------------------------------------------------------------
// Message type
// --------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Ping {
id: String,
}
// --------------------------------------------------------------------------
// Topic
//
// The queue name contains a per-run UUID so reruns never collide on the same
// cluster. We initialize the topology once via `OnceLock` before starting
// the broker and then return the same `&'static` reference on every call.
// --------------------------------------------------------------------------
static PING_TOPOLOGY: OnceLock<shove::QueueTopology> = OnceLock::new();
struct PingTopic;
impl Topic for PingTopic {
type Message = Ping;
type Codec = JsonCodec;
fn topology() -> &'static shove::QueueTopology {
PING_TOPOLOGY
.get()
.expect("PING_TOPOLOGY must be initialised before using PingTopic")
}
}
// --------------------------------------------------------------------------
// Handler
// --------------------------------------------------------------------------
struct PingHandler;
impl MessageHandler<PingTopic> for PingHandler {
type Context = ();
async fn handle(&self, message: Ping, _metadata: MessageMetadata, _: &()) -> Outcome {
println!("Received ping: id={}", message.id);
Outcome::Ack
}
}
// --------------------------------------------------------------------------
// Main
// --------------------------------------------------------------------------
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let brokers = std::env::var("MSK_BROKERS").unwrap_or_else(|_| {
eprintln!("Error: MSK_BROKERS environment variable is not set.");
eprintln!("Set it to the comma-separated bootstrap broker string of your MSK cluster.");
eprintln!("Example: MSK_BROKERS=b-1.example.kafka.us-east-1.amazonaws.com:9198");
std::process::exit(1);
});
let region = std::env::var("AWS_REGION").unwrap_or_else(|_| {
eprintln!("Error: AWS_REGION environment variable is not set.");
eprintln!("Set it to the AWS region where your MSK cluster lives.");
eprintln!("Example: AWS_REGION=us-east-1");
std::process::exit(1);
});
// Build the per-run topic name and initialise the static topology.
let topic_name = format!("shove-msk-iam-example-{}", Uuid::new_v4().simple());
PING_TOPOLOGY
.set(TopologyBuilder::new(&topic_name).build())
.expect("PING_TOPOLOGY already initialised");
println!("Using topic: {topic_name}");
// Connect.
let config = KafkaConfig::new(&brokers)
.with_tls(KafkaTls::default())
.with_sasl(KafkaSasl::msk_iam(®ion));
println!("Connecting to MSK cluster at {brokers} (region={region}) …");
let broker = Broker::<Kafka>::new(config).await?;
println!("Connected.");
// Declare the topic.
broker.topology().declare::<PingTopic>().await?;
// Publish one message.
let publisher = broker.publisher().await?;
let ping_id = Uuid::new_v4().to_string();
publisher
.publish::<PingTopic>(&Ping {
id: ping_id.clone(),
})
.await?;
println!("Published ping: id={ping_id}");
// Consume until the message arrives (up to 10 s) or ctrl-c.
let mut group = broker.consumer_group();
group
.register::<PingTopic, _>(
ConsumerGroupConfig::new(KafkaConsumerGroupConfig::new(1..=1)),
|| PingHandler,
)
.await?;
let outcome = group
.run_until_timeout(
async {
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(10)) => {}
_ = tokio::signal::ctrl_c() => {}
}
},
Duration::from_secs(15),
)
.await;
println!("Done.");
std::process::exit(outcome.exit_code());
}Walkthrough
IAM auth on the KafkaConfig
let config = KafkaConfig::new(&brokers)
.with_tls(KafkaTls::default())
.with_sasl(KafkaSasl::msk_iam(®ion));KafkaTls::default() is the right TLS configuration for MSK — clusters use publicly-signed ACM certificates, so the OS trust store handles validation. There's nothing to download, no CA path to set.
KafkaSasl::msk_iam(region) selects the OAUTHBEARER mechanism and binds the token signer to region. Behind the scenes shove sets security.protocol=SASL_SSL, sasl.mechanism=OAUTHBEARER, and installs a custom ClientContext whose token-refresh callback signs a fresh presigned URL via aws-msk-iam-sasl-signer whenever librdkafka asks for one (every ~12 minutes at default expiry).
If you forget to call with_tls(...) for an MSK IAM config, Broker::<Kafka>::new returns ShoveError::Topology at connect time — presigned tokens over plaintext are a misconfiguration, not a runtime fallback.
Per-run topic name
let topic_name = format!("shove-msk-iam-example-{}", Uuid::new_v4().simple());
PING_TOPOLOGY
.set(TopologyBuilder::new(&topic_name).build())
.expect("PING_TOPOLOGY already initialised");The Topic::topology() method must return a &'static QueueTopology, but the topic name needs to differ across runs so reruns don't collide on the cluster. The example resolves this with a OnceLock initialised in main before the broker starts — the topology is set exactly once, then returned by reference forever after.
Topic provisioning
broker.topology().declare::<PingTopic>().await?;declare creates the Kafka topics that back the topology (one main topic plus DLQ + hold queues if configured). The caller's IAM principal needs kafka-cluster:CreateTopic on the cluster ARN. In production where topology is pre-provisioned by infrastructure, this call is idempotent — it does nothing if the topics already exist with sufficient partitions.
Single consumer in a group
group
.register::<PingTopic, _>(
ConsumerGroupConfig::new(KafkaConsumerGroupConfig::new(1..=1)),
|| PingHandler,
)
.await?;1..=1 pins exactly one consumer in the group, which keeps the example deterministic. For real workloads, see the Basic example for how the group scales partition assignment dynamically.
Token rotation
Tokens are signed locally — no network call to AWS per refresh. librdkafka schedules a refresh at ~20% of the remaining lifetime (so ~3 minutes before expiry for a 15-minute token). The refresh callback runs on librdkafka's internal poll thread; shove bridges it to the async AWS signer via a Tokio runtime handle captured at connect() time.
Do not set sasl.oauthbearer.config manually. The shove integration owns this property.
What to try next
- Stop the running example mid-flight, revoke the caller's
kafka-cluster:Connectpermission for ~30 seconds, then restore it — librdkafka will report auth failures during the gap and recover on the next refresh. - Switch to
KafkaSasl::msk_iam_with_profile(region, "production")and confirm shove reads credentials from the named profile instead of the default chain. - Wire this same code into a long-running consumer service and observe token-refresh log lines from librdkafka over a multi-hour run.
- See the Kafka backend overview for the cargo feature matrix and other auth modes.