SNS and SQS - what a wonderful pair of services. They are very often used together to send messages between services or create a fan-out system. SNS is also a good companion to use with CloudWatch Alarms to send you an email at 2AM that the CPU of your EC2 instance is at 90% since 4 hours. Although these two are closely related to each other, their at-rest encryption methods are absolutely different (or not that much maybe?)
Firstly, SQS has a very similar approach to encryption as S3 - you can use your own KMS key, leave unencrypted or use so called managed encryption - where AWS does everything for you transparently. SNS on the other hand can only be unencrypted or use KMS key for encryption. For that purpose you are also provided fortunately with a default KMS key of alias alias/aws/sns that works out of the box and you don't ever see the underlying key.
In this project we are going to implement the following setup - first we gonna create SNS and SQS topics unencrypted in the receiver account. We will allow anyone from the sender account to publish messages to both. In the sender account I will create a small Lambda that will do just that - publish a message with date and time, log any errors and have permissions from the sender account to even use SNS and SQS. See image below.
The coding part 💻
As we are in almost 2027, of course I use agents to generate the code. I guided it to create required resources in the sender's and receiver's account. But the most important bits are the following.
Resource policies for the cross-account access
These are the policies for both SQS queue and SNS topic that allow anyone from the sender's account to write there. Actually it's more like we are giving control over to the admins of sender's account because they still have to allow the users and roles there to use SNS or SQS services, regardless of target account (resource policies only affect users/roles of the current account).
data "aws_iam_policy_document" "sns" {
provider = aws.receiver
statement {
sid = "AllowSenderAccount"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.sender}:root"]
}
actions = [
"sns:Publish",
"sns:GetTopicAttributes",
]
resources = [aws_sns_topic.this.arn]
}
}
data "aws_iam_policy_document" "sqs" {
provider = aws.receiver
statement {
sid = "AllowSenderAccount"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.sender}:root"]
}
actions = [
"sqs:SendMessage",
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl",
]
resources = [aws_sqs_queue.this.arn]
}
}
Lambda on the sender's side
This is the Lambda I asked the agent to create (and adapted it to my liking) that will let us test the messages. It simply pushes the messages and in case of any problems, logs to CloudWatch.
import logging, os, boto3
from datetime import datetime, timezone
logger = logging.getLogger()
logger.setLevel(logging.INFO)
sns = boto3.client("sns")
sqs = boto3.client("sqs")
SNS_TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]
SQS_QUEUE_URL = os.environ["SQS_QUEUE_URL"]
def handler(event, context):
now = datetime.now(timezone.utc)
message = f"{now:%A}, {now.day} {now:%B %Y} at {now:%H:%M} UTC"
if isinstance(event, dict) and "body" in event:
message = f"{message} [{event['body']}]"
message_sqs = f"Lambda to SQS: {message}"
message_sns = f"Lambda to SNS: {message}"
try:
sqs.send_message(QueueUrl=SQS_QUEUE_URL, MessageBody=message_sqs)
sns.publish(TopicArn=SNS_TOPIC_ARN, Message=message_sns)
except Exception as e:
logger.exception(f"failed to send message: {message} Error:\n{e}")
return {"statusCode": 500, "body": str(e)}
return {"statusCode": 200, "body": message}
But in order to either submit messages or even send logs and metrics, we need an appropriate IAM role with the following permissions. Of course the list below can be stricter but this is enough for an example.
data "aws_iam_policy_document" "lambda_messaging" {
provider = aws.sender
statement {
effect = "Allow"
actions = [
"sns:Publish",
"sns:GetTopicAttributes",
"sqs:SendMessage",
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
provider = aws.sender
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
If you wish to see the full code for current state, check out the GitHub repository where I published the tag unencrypted.
Testing the messaging ✉️
Now as we have all the code that is needed, we can test the flow for unencrypted services. When reading from SQS we should ideally delete the message afterwards. For SNS I set up an email subscription for myself. You can also use another SQS as the target or do whatever you like. It's most convenient to invoke Lambda from the CLI. I labeled what are we testing in the message body so that we can track each configuration. Use /dev/stdout because invoke expects a file to redirect output to (for whatever reason).
$ aws lambda invoke \
--no-cli-pager \
--function-name "$(tofu output -raw lambda_function_name)" \
--cli-binary-format raw-in-base64-out \
--payload '{"body":"Unencrypted"}' \
/dev/stdout
{"statusCode": 200, "body": "Sunday, 20 September 2026 at 20:06 UTC [Unencrypted]"}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
We got a success code. Now we can look into the SQS queue and check if the message matches. Afterwards, I checked my email to see the message from SNS. On SQS receive, save the receipt handle to delete the message and keep the queue clean.
$ aws sqs receive-message \
--no-cli-pager \
--queue-url "$(tofu output -raw sqs_queue_url)" \
--wait-time-seconds 10 \
--max-number-of-messages 1
{
"Messages": [
{
"MessageId": "070ce8a8-628a-4639-8db8-614559e1af0a",
"ReceiptHandle": "AQEB2Ws7dt5D6Hr2Kur...",
"MD5OfBody": "b73ce6423782c44b7ce435440b0bbb3b",
"Body": "Lambda to SQS: Sunday, 20 September 2026 at 20:06 UTC [Unencrypted]"
}
]
}
$ aws sqs delete-message \
--queue-url "$(tofu output -raw sqs_queue_url)" \
--receipt-handle AQEB2Ws7dt5D6Hr2K...
Enabling encryption 🔐
Let's do the first thing - we are going to enable encryption on SQS and use the minimum managed mode called AWS-SQS. This operation is completely transparent. Previous unencrypted messages remain unencrypted and can be still read by the consumers but all the newly arriving messages will be encrypted at rest. Neither senders nor receivers see any change.
resource "aws_sqs_queue" "this" {
provider = aws.receiver
name = random_pet.provision.id
sqs_managed_sse_enabled = true # 👈 Changed
}
After applying you can see that everything still works as expected - the Lambda can send messages and you can just read them from the CLI. The code change is minor and stored under tag sqs-encrypted.
$ aws lambda invoke \
--no-cli-pager \
--function-name "$(tofu output -raw lambda_function_name)" \
--cli-binary-format raw-in-base64-out \
--payload '{"body":"SQS Encrypted"}' \
/dev/stdout
{"statusCode": 200, "body": "Monday, 21 September 2026 at 12:37 UTC [SQS Encrypted]"}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ aws sqs receive-message \
--no-cli-pager \
--queue-url "$(tofu output -raw sqs_queue_url)" \
--wait-time-seconds 10 \
--max-number-of-messages 1
{
"Messages": [
{
"MessageId": "c81726bb-8d70-4838-b20f-e57f186f6c05",
"ReceiptHandle": "AQEBGV1iVujN+5AhKE...",
"MD5OfBody": "5267fb95f1e9a8b8beb85471efa9e9a3",
"Body": "Lambda to SQS: Monday, 21 September 2026 at 12:37 UTC [SQS Encrypted]"
}
]
}
For SNS we don't have such mode with managed encryption. Fortunately, AWS provides us with a default KMS key that will allow us to enable encryption on SNS topics. Let's try this 🚀!
resource "aws_sns_topic" "this" {
provider = aws.receiver
name = random_pet.provision.id
kms_master_key_id = "alias/aws/sns" # 👈 added
}
Now we are going to send the message again the same way as we did when testing before. Let's see how the Lambda behaves. See this tag for code changes sns-default-kms.
$ aws lambda invoke \
--no-cli-pager \
--function-name "$(tofu output -raw lambda_function_name)" \
--cli-binary-format raw-in-base64-out \
--payload '{"body":"SNS Default KMS"}' \
/dev/stdout
{"statusCode": 500, "body": "An error occurred (KMSAccessDenied) when calling the Publish operation: User: arn:aws:sts::012345678901:assumed-role/sns-sqs-encrypt-lambda/sns-sqs-encrypt is not authorized to perform: kms:GenerateDataKey on this resource because the resource does not exist in this Region, no resource-based policies allow access, or a resource-based policy explicitly denies access (Service: AWSKMS; Status Code: 400; Error Code: AccessDeniedException; Request ID: 5b2916e5-ecf8-48a5-8021-5d642c35f61b; Proxy: null)"}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
As you can see using default KMS is not the way for our use case. This default key only allows principals from the same account to access it. In order to perform cross account operations, we need a customer managed KMS key with custom resource policy. We are going to create this one now and swap it in the topic. Changes are tracked in this tag: sns-custom-kms.
data "aws_caller_identity" "receiver" {
provider = aws.receiver
}
data "aws_iam_policy_document" "kms" {
provider = aws.receiver
statement {
sid = "EnableOwnAccount"
effect = "Allow"
actions = ["kms:*"]
resources = ["*"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.receiver.account_id}:root"]
}
}
statement {
sid = "AllowSenderAccount"
effect = "Allow"
actions = [
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:DescribeKey",
]
resources = ["*"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.sender}:root"]
}
}
}
resource "aws_kms_key" "sns" {
provider = aws.receiver
description = "SNS Key for ${random_pet.provision.id}"
enable_key_rotation = true
deletion_window_in_days = 7
policy = data.aws_iam_policy_document.kms.json
}
resource "aws_kms_alias" "sns" {
provider = aws.receiver
name = "alias/sns-${random_pet.provision.id}"
target_key_id = aws_kms_key.sns.id
}
### Changes to SNS
resource "aws_kms_alias" "sns" {
provider = aws.receiver
name = "alias/sns-${random_pet.provision.id}"
target_key_id = aws_kms_alias.sns.name # 👈 changed the alias
}
Even though we have created the custom key that we have approved for use by the sender account this is still failing. Do you know why 🤔?
$ aws lambda invoke \
--no-cli-pager \
--function-name "$(tofu output -raw lambda_function_name)" \
--cli-binary-format raw-in-base64-out \
--payload '{"body":"SNS Custom KMS"}' \
/dev/stdout
{"statusCode": 500, "body": "An error occurred (KMSAccessDenied) when calling the Publish operation: User: arn:aws:sts::730335385312:assumed-role/sns-sqs-encrypt-lambda/sns-sqs-encrypt is not authorized to perform: kms:GenerateDataKey on this resource because no identity-based policy allows the kms:GenerateDataKey action (Service: AWSKMS; Status Code: 400; Error Code: AccessDeniedException; Request ID: 2e339b65-e15d-466b-b0c0-89a61cb03a93; Proxy: null)"}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
That's right! Just because the KMS on receiver's account allows complete sender's account to use the key, you still need to allow this role to perform anything related to KMS on the sender's account - even when you specify the role explicitly in key's resource policy. Let's try changing this and running again. I will allow the Lambda to use any key from the receiver's account (and let it control the true access to KMS). The code is under tag lambda-allow-kms.
data "aws_iam_policy_document" "lambda_messaging" {
provider = aws.sender
# ... 👇 added a statement
statement {
effect = "Allow"
actions = [
"kms:GenerateDataKey*",
"kms:Decrypt",
"kms:DescribeKey",
]
resources = [
"arn:aws:kms:eu-west-1:${var.receiver}:key/*",
"arn:aws:kms:eu-west-1:${var.receiver}:alias/*"
]
}
}
$ aws lambda invoke \
--no-cli-pager \
--function-name "$(tofu output -raw lambda_function_name)" \
--cli-binary-format raw-in-base64-out \
--payload '{"body":"SNS Custom KMS 2"}' \
/dev/stdout
{"statusCode": 200, "body": "Monday, 21 September 2026 at 16:33 UTC [SNS Custom KMS 2]"}{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
And now I also received the email! You might be wondering, how could SNS as the service decrypt the message? After all the topic has no role in our account so the natural thing would be to give sns.amazonaws.com permissions on the key. What happens in the background when you do Terraform apply is that your user performs kms:CreateGrant for the entire SNS service when attaching the alias. This grant doesn't have expiration date and so it simplified the workflow a bit! Speaking of services...
What about CloudWatch Alarms ⏰?
Another use case for SNS topics is a CloudWatch Alarm. Let's create a new one even on the same, receiver's account and set it to if SQS available messages > 5 in the last 5 minutes then send message to SNS. For that I will for now use the a new SNS topic with the default AWS provided KMS key.
resource "aws_cloudwatch_metric_alarm" "sqs_visible" {
provider = aws.receiver
alarm_name = "${aws_sqs_queue.this.name}-visible-messages"
alarm_description = "Visible messages in the receiver SQS queue are greater than 5"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Maximum"
threshold = 5
alarm_actions = [aws_sns_topic.this.arn]
dimensions = {
QueueName = aws_sqs_queue.this.name
}
}
resource "aws_sns_topic" "cloudwatch" {
provider = aws.receiver
name = "${aws_sqs_queue.this.name}-visible-messages"
kms_master_key_id = "alias/aws/sns"
}
data "aws_iam_policy_document" "sns_cloudwatch" {
provider = aws.receiver
statement {
sid = "AllowCloudWatch"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudwatch.amazonaws.com"]
}
actions = ["sns:Publish"]
resources = [aws_sns_topic.cloudwatch.arn]
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.receiver.account_id]
}
}
}
# ...
I applied the infrastructure and created plenty of messages on SQS. And the email never arrived. The reason is easily visible in the console when you review alarm's history.
To fix this, we have to allow cloudwatch.amazonaws.com and events.amazonaws.com as the service principals that can use the key. Default KMS key doesn't include these, so we need to again use CMK. I will simply reuse the key we already created but add some permissions to it. Code below allows only CloudWatch and EventBridge resources from current account to use this key.
Compare these tags cloudwatch-default-kms and cloudwatch-custom-kms.
data "aws_iam_policy_document" "kms" {
provider = aws.receiver
# ...
statement {
sid = "AllowCloudWatchAndEvents"
effect = "Allow"
actions = [
"kms:GenerateDataKey*",
"kms:Decrypt",
]
resources = ["*"]
principals {
type = "Service"
identifiers = [
"cloudwatch.amazonaws.com",
"events.amazonaws.com",
]
}
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.receiver.account_id]
}
}
}
# ...
resource "aws_sns_topic" "cloudwatch" {
provider = aws.receiver
name = "${aws_sqs_queue.this.name}-visible-messages"
kms_master_key_id = aws_kms_alias.sns.name # 👈 this changed
}
After applying here and pushing more messages on the queue (ideally empty the queue first and then add 20 or so messages) we receive the email successfully. The alarm triggers and CloudWatch can use our custom CMK with added permissions. So remember that not only cross-account access but also any integration with AWS services requires a custom key with custom permissions!
Cross account subscribers 🔀
I did so far only examples where the email is the subscriber. What if I want to subscribe another SQS queue in another account? We can try that too. For this I will perform two tests - the default KMS and a custom one with just current account permissions. I will create two SNS Topics in the sender account and subscribe with queues on the receiver account.
resource "aws_sns_topic" "default_kms_sender" {
provider = aws.sender
name = "${random_pet.provision.id}-sender-default-kms"
kms_master_key_id = "alias/aws/sns"
}
resource "aws_kms_key" "sender_sns" {
provider = aws.sender
description = "SNS Key for ${random_pet.provision.id} sender-custom-kms"
enable_key_rotation = true
deletion_window_in_days = 7
policy = data.aws_iam_policy_document.sender_sns_kms.json # defined later
}
resource "aws_kms_alias" "sender_sns" {
provider = aws.sender
name = "alias/sns-${random_pet.provision.id}-sender"
target_key_id = aws_kms_key.sns.id
}
resource "aws_sns_topic" "custom_kms_sender" {
provider = aws.sender
name = "${aws_sqs_queue.this.name}-sender-custom-kms"
kms_master_key_id = aws_kms_alias.sender_sns.name
}
Now for both of the topics I will define IAM permissions that will allow anyone from the receiver account to subscribe to them. This way we will be able to add the SQS queue subscriptions. For the CMK, I will for now just leave the minimal permissions which is let sender's account IAM control the access.
# SNS Permissions
data "aws_iam_policy_document" "sender_sns" {
statement {
actions = ["sns:Subscribe"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.receiver}:root"]
}
resources = ["arn:aws:sns:*:${var.sender}:*"]
}
}
resource "aws_sns_topic_policy" "default_kms_sender" {
provider = aws.sender
arn = aws_sns_topic.default_kms_sender.arn
policy = data.aws_iam_policy_document.sender_sns.json
}
resource "aws_sns_topic_policy" "custom_kms_sender" {
provider = aws.sender
arn = aws_sns_topic.custom_kms_sender.arn
policy = data.aws_iam_policy_document.sender_sns.json
}
# KMS Permissions
data "aws_iam_policy_document" "sender_sns_kms" {
provider = aws.sender
statement {
actions = ["kms:*"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.sender}:root"]
}
resources = ["*"]
}
}
Now I will define the SQS queues and their subscriptions. They will reside in the receiver's account. As defined in the SNS resource policy earlier, we are allowing anyone from the receiver's account to subscribe to these topics.
resource "aws_sqs_queue" "from_default_kms" {
provider = aws.receiver
name = "${random_pet.provision.id}-from-default-kms"
}
resource "aws_sqs_queue" "from_custom_kms" {
provider = aws.receiver
name = "${random_pet.provision.id}-from-custom-kms"
}
resource "aws_sns_topic_subscription" "from_default_kms" {
provider = aws.receiver
topic_arn = aws_sns_topic.default_kms_sender.arn
protocol = "sqs"
endpoint = aws_sqs_queue.from_default_kms.arn
}
resource "aws_sns_topic_subscription" "from_custom_kms" {
provider = aws.receiver
topic_arn = aws_sns_topic.custom_kms_sender.arn
protocol = "sqs"
endpoint = aws_sqs_queue.from_custom_kms.arn
}
The subscription is confirmed meaning that it should work. But despite that we need to still provide permissions for SNS to push to these SQS queues - the subscription itself doesn't do the job. We do it with the following policy (the example one is broader).
data "aws_iam_policy_document" "sns_publish_to_sqs" {
provider = aws.receiver
statement {
actions = ["sqs:SendMessage"]
resources = ["*"]
principals {
type = "Service"
identifiers = ["sns.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [var.sender]
}
}
}
resource "aws_sqs_queue_policy" "from_default_kms" {
provider = aws.receiver
queue_url = aws_sqs_queue.from_default_kms.id
policy = data.aws_iam_policy_document.sns_publish_to_sqs.json
}
resource "aws_sqs_queue_policy" "from_custom_kms" {
provider = aws.receiver
queue_url = aws_sqs_queue.from_custom_kms.id
policy = data.aws_iam_policy_document.sns_publish_to_sqs.json
}
Let's now submit two messages using CLI. One will be sent to the topic with custom KMS (full permissions of the account where SNS topic resides) and also the AWS provided default key. I assume you are using a user with higher privileges that is not constrained by IAM policies regarding KMS usage.
$ aws sns publish \
--topic-arn $(tofu output -raw default_kms_sender_topic_arn) \
--message "Test message to default KMS topic" \
--no-cli-pager
{
"MessageId": "dc91f57c-ad26-575e-83ac-9649718ae18b"
}
$ aws sns publish \
--topic-arn $(tofu output -raw custom_kms_sender_topic_arn) \
--message "Test message to custom KMS topic" \
--no-cli-pager
{
"MessageId": "2b549595-a806-5d0c-af27-f6a074a0a672"
}
So far we got no errors. The messages were simply accepted by SNS and scheduled for fan-out. Let's check now what is in our queues on the receiver's account. I created outputs for the URLs of the queues in Terraform. See this tag sns-sqs-cross-acc-sub.
$ aws sqs receive-message \
--queue-url $(tofu output -raw from_default_kms_queue_url) \
--max-number-of-messages 5 \
--no-cli-pager
{
"Messages": [
{
"MessageId": "0cfa4746-e670-4481-b180-ad39fcc29e4a",
"ReceiptHandle": "AQEBbarX...",
"MD5OfBody": "f39a69056303d658ca7e3249094051ed",
"Body": "{\n \"Type\" : \"Notification\",\n \"MessageId\" : \"dc91f57c-ad26-575e-83ac-9649718ae18b\",\n
\"TopicArn\" : \"arn:aws:sns:eu-west-1:012345678901:sns-sqs-encrypt-fond-gecko-sender-default-kms\",\n
\"Message\" : \"Test message to default KMS topic\",\n
\"Timestamp\" : \"2026-09-29T15:34:04.882Z\",\n...}"
}
]
}
$ aws sqs receive-message \
--queue-url $(tofu output -raw from_custom_kms_queue_url) \
--max-number-of-messages 5 \
--no-cli-pager
{
"Messages": [
{
"MessageId": "bdad8576-182f-4aa2-bf6d-9a16cd3d5631",
"ReceiptHandle": "AQEB/1WGJxuZilS84BX...",
"MD5OfBody": "a02de66e9a87e35a5f47d04bb9986c96",
"Body": "{\n \"Type\" : \"Notification\",\n \"MessageId\" : \"2b549595-a806-5d0c-af27-f6a074a0a672\",\n
\"TopicArn\" : \"arn:aws:sns:eu-west-1:012345678901:sns-sqs-encrypt-fond-gecko-sender-custom-kms\",\n
\"Message\" : \"Test message to custom KMS topic\",\n
\"Timestamp\" : \"2026-09-29T15:33:58.368Z\",\n ...}"
}
]
}
As you can see we received the messages onto the queues even though we never gave any permissions for KMS to the receiver's account nor SQS service! This is because SNS takes care of the decryption of the messages before pushing. Just as with e-mail, where we can't expect the SMTP provider to go back to AWS and ask to decrypt the message, the protocol is implemented the same way for all the subscriptions. First you (or Lambda) encrypt the message (transparently) with KMS key pointed by the SNS Topic, SNS buffers it somewhere and then SNS service decrypts the message (using key grant) and then sends the encrypted message forward.







Top comments (0)