Repository: centrifugal/centrifugo (2026-04-09T18:38:00Z)

Issue:
[feature] Add STS AssumeRole (cross-account IAM) support for Kafka async consumer
**Is your feature request related to a problem? Please describe.**

Currently, Centrifugo's Kafka async consumer supports AWS MSK IAM ("aws-msk-iam" SASL mechanism) by configuring AccessKey/SecretKey via the SASL user/password fields. However, it is missing support for using [AWS STS AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) for IAM authentication. This is needed to easily and securely connect to MSK clusters hosted in *other* AWS accounts, or when following best practices that require only temporary credentials.

Many organizations use cross-account setups (for instance, running Centrifugo in one AWS account and hosting MSK in another) and would prefer to use role assumption instead of sharing or rotating long-term IAM keys. This would also make it consistent with the SQS consumer, which already supports an `assume_role_arn` config field for cross-account access.

**Describe the solution you'd like.**

- Add `assume_role_arn` (or similar) and, optionally, `credentials_profile` options to the Kafka consumer config, matching the SQS consumer configuration style.
- At runtime, if the option is set, Centrifugo should use AWS STS (via the SDK) to obtain temporary credentials and use those for SASL authentication.
- The configuration should continue to support direct key input for backwards compatibility, but if both are set, `assume_role_arn` should take precedence.
- Document this option in the Kafka consumer section of the docs, including an example cross-account scenario.

**Who will benefit from this feature?**

- Teams needing to securely connect Centrifugo to MSK in other AWS accounts
- Users following AWS best practices (no long-lived IAM users/keys)
- Environments requiring tighter IAM and audit controls

**Additional context**
- SQS consumer already supports assume role via `assume_role_arn`. Parity in Kafka consumer would be very useful.
- This feature would use the AWS Go SDK's `stscreds` provider under the hood, as with SQS.


Discussion:
@FZambia - can you please review - https://github.com/centrifugal/centrifugo/pull/1129



Patch:
diff --git a/internal/cli/configdoc/schema.json b/internal/cli/configdoc/schema.json
index 3947383f5..584f61d17 100644
--- a/internal/cli/configdoc/schema.json
+++ b/internal/cli/configdoc/schema.json
@@ -7329,6 +7329,16 @@
             "comment": "",
             "is_complex_type": false
           },
+          {
+            "field": "consumers[].kafka.assume_role_arn",
+            "name": "assume_role_arn",
+            "go_name": "AssumeRoleARN",
+            "level": 3,
+            "type": "string",
+            "default": "",
+            "comment": "AssumeRoleARN, when set with sasl_mechanism aws-msk-iam, uses AWS STS AssumeRole to obtain\ntemporary credentials for MSK IAM authentication (e.g. cross-account MSK). Base credentials\nand region come from the default AWS SDK chain (AWS_REGION, instance metadata, etc.).\nWhen set, sasl_user and sasl_password are ignored. When empty, static keys from sasl_user/sasl_password are used.",
+            "is_complex_type": false
+          },
           {
             "field": "consumers[].kafka.instance_id",
             "name": "instance_id",
diff --git a/internal/configtypes/types.go b/internal/configtypes/types.go
index 93dfb3b7d..73636ded3 100644
--- a/internal/configtypes/types.go
+++ b/internal/configtypes/types.go
@@ -813,6 +813,11 @@ type KafkaConsumerConfig struct {
 	SASLMechanism string `mapstructure:"sasl_mechanism" json:"sasl_mechanism" envconfig:"sasl_mechanism" yaml:"sasl_mechanism" toml:"sasl_mechanism"`
 	SASLUser      string `mapstructure:"sasl_user" json:"sasl_user" envconfig:"sasl_user" yaml:"sasl_user" toml:"sasl_user"`
 	SASLPassword  string `mapstructure:"sasl_password" json:"sasl_password" envconfig:"sasl_password" yaml:"sasl_password" toml:"sasl_password"`
+	// AssumeRoleARN, when set with sasl_mechanism aws-msk-iam, uses AWS STS AssumeRole to obtain
+	// temporary credentials for MSK IAM authentication (e.g. cross-account MSK). Base credentials
+	// and region come from the default AWS SDK chain (AWS_REGION, instance metadata, etc.).
+	// When set, sasl_user and sasl_password are ignored. When empty, static keys from sasl_user/sasl_password are used.
+	AssumeRoleARN string `mapstructure:"assume_role_arn" json:"assume_role_arn" envconfig:"assume_role_arn" yaml:"assume_role_arn" toml:"assume_role_arn"`
 
 	// InstanceID sets a stable consumer group instance ID for Kafka static membership.
 	// When set, enables the static membership protocol: during rolling restarts, a replacement
@@ -843,6 +848,9 @@ func (c KafkaConsumerConfig) Validate() error {
 	if c.PublicationDataMode.Enabled && c.PublicationDataMode.ChannelsHeader == "" {
 		return errors.New("no Kafka channels_header_name provided for publication data mode")
 	}
+	if c.AssumeRoleARN != "" && c.SASLMechanism != "aws-msk-iam" {
+		return errors.New("assume_role_arn requires sasl_mechanism aws-msk-iam")
+	}
 	return nil
 }
 
diff --git a/internal/consuming/kafka.go b/internal/consuming/kafka.go
index 5e3dc8113..be71d2eea 100644
--- a/internal/consuming/kafka.go
+++ b/internal/consuming/kafka.go
@@ -16,10 +16,14 @@ import (
 	"github.com/centrifugal/centrifugo/v6/internal/logging"
 	"github.com/centrifugal/centrifugo/v6/internal/metrics"
 
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsv2cfg "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
+	"github.com/aws/aws-sdk-go-v2/service/sts"
 	"github.com/rs/zerolog"
 	"github.com/twmb/franz-go/pkg/kgo"
 	"github.com/twmb/franz-go/pkg/kmsg"
-	"github.com/twmb/franz-go/pkg/sasl/aws"
+	mskaws "github.com/twmb/franz-go/pkg/sasl/aws"
 	"github.com/twmb/franz-go/pkg/sasl/plain"
 	"github.com/twmb/franz-go/pkg/sasl/scram"
 )
@@ -159,6 +163,17 @@ func (c *KafkaConsumer) leaveGroup(ctx context.Context, client *kgo.Client) erro
 	return err
 }
 
+func loadAWSConfigForKafkaAssumeRole(ctx context.Context, roleARN string) (aws.Config, error) {
+	awsCfg, err := awsv2cfg.LoadDefaultConfig(ctx)
+	if err != nil {
+		return aws.Config{}, err
+	}
+	stsClient := sts.NewFromConfig(awsCfg)
+	assumeRoleProvider := stscreds.NewAssumeRoleProvider(stsClient, roleARN)
+	awsCfg.Credentials = aws.NewCredentialsCache(assumeRoleProvider)
+	return awsCfg, nil
+}
+
 func (c *KafkaConsumer) initClient() (*kgo.Client, error) {
 	opts := []kgo.Opt{
 		kgo.SeedBrokers(c.config.Brokers...),
@@ -214,10 +229,30 @@ func (c *KafkaConsumer) initClient() (*kgo.Client, error) {
 				Pass: c.config.SASLPassword,
 			}.AsSha512Mechanism()))
 		case "aws-msk-iam":
-			opts = append(opts, kgo.SASL(aws.Auth{
-				AccessKey: c.config.SASLUser,
-				SecretKey: c.config.SASLPassword,
-			}.AsManagedStreamingIAMMechanism()))
+			if c.config.AssumeRoleARN != "" {
+				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+				awsCfg, err := loadAWSConfigForKafkaAssumeRole(ctx, c.config.AssumeRoleARN)
+				cancel()
+				if err != nil {
+					return nil, fmt.Errorf("load AWS config for MSK IAM assume role: %w", err)
+				}
+				opts = append(opts, kgo.SASL(mskaws.ManagedStreamingIAM(func(ctx context.Context) (mskaws.Auth, error) {
+					v, err := awsCfg.Credentials.Retrieve(ctx)
+					if err != nil {
+						return mskaws.Auth{}, err
+					}
+					return mskaws.Auth{
+						AccessKey:    v.AccessKeyID,
+						SecretKey:    v.SecretAccessKey,
+						SessionToken: v.SessionToken,
+					}, nil
+				})))
+			} else {
+				opts = append(opts, kgo.SASL(mskaws.Auth{
+					AccessKey: c.config.SASLUser,
+					SecretKey: c.config.SASLPassword,
+				}.AsManagedStreamingIAMMechanism()))
+			}
 		default:
 			return nil, fmt.Errorf("unsupported SASL mechanism: %s", c.config.SASLMechanism)
 		}


Test patch:
diff --git a/internal/configtypes/kafka_consumer_test.go b/internal/configtypes/kafka_consumer_test.go
new file mode 100644
index 000000000..2b29f75d5
--- /dev/null
+++ b/internal/configtypes/kafka_consumer_test.go
@@ -0,0 +1,39 @@
+package configtypes
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestKafkaConsumerConfigValidate_AssumeRoleARN(t *testing.T) {
+	base := KafkaConsumerConfig{
+		Brokers:       []string{"localhost:9092"},
+		Topics:        []string{"t"},
+		ConsumerGroup: "g",
+	}
+
+	t.Run("assume_role_arn with aws-msk-iam is valid", func(t *testing.T) {
+		c := base
+		c.SASLMechanism = "aws-msk-iam"
+		c.AssumeRoleARN = "arn:aws:iam::123456789012:role/MSKAccess"
+		require.NoError(t, c.Validate())
+	})
+
+	t.Run("assume_role_arn without aws-msk-iam is invalid", func(t *testing.T) {
+		c := base
+		c.SASLMechanism = "plain"
+		c.AssumeRoleARN = "arn:aws:iam::123456789012:role/MSKAccess"
+		err := c.Validate()
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "assume_role_arn requires sasl_mechanism aws-msk-iam")
+	})
+
+	t.Run("assume_role_arn with empty sasl_mechanism is invalid", func(t *testing.T) {
+		c := base
+		c.AssumeRoleARN = "arn:aws:iam::123456789012:role/MSKAccess"
+		err := c.Validate()
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "assume_role_arn requires sasl_mechanism aws-msk-iam")
+	})
+}