When S3 Encryption Breaks Lambda: Recovering with an SQS DLQ (S3 → Lambda → DynamoDB)

 

When S3 Encryption Breaks Lambda: Recovering with an SQS DLQ (S3 → Lambda → DynamoDB)

When KMS policies silently block your Lambda reads, the danger isn’t the failure itself — it’s not knowing it happened.





A single encryption setting can quietly cripple your S3 → Lambda → DynamoDB workflow.

We’ll build the system, break it on purpose, observe the failure, capture the fallout with a DLQ, fix the permissions, and replay the lost events until the pipeline is whole again.


Problem

Your S3 bucket triggers Lambda to process files and write metadata into DynamoDB.

Then someone enables SSE-KMS encryption—or tightens access—and the function still runs but no longer writes data.

CloudWatch shows AccessDenied.


Clarifying the Issue

S3 delivers the event, Lambda tries to read the object, and KMS checks permissions.

If the Lambda role isn’t trusted by the KMS key or bucket, the read fails instantly.

S3 doesn’t retry. Without a DLQ, the event disappears.


Why It Matters

These are silent failures. Your dashboards look fine while data quietly drifts.

A DLQ surfaces every lost event and lets you recover them later instead of accepting bad counts and incomplete analytics.


Key Terms

AccessDenied — Missing permission or KMS grant blocking S3 read.
SSE-KMS — Server-side encryption using AWS KMS keys. (The most common way to break least-privilege Lambda functions.)
DLQ — Dead-Letter Queue for failed Lambda events.
Replay — Re-invoking stored DLQ messages after repairing permissions.


Steps at a Glance

  1. Create the baseline pipeline.
  2. Test the pipeline.
  3. Introduce the failure.
  4. Observe the error.
  5. Add and test a DLQ while still broken.
  6. Apply the fix.
  7. Replay and verify recovery.
  8. Re-Test and confirm.
  9. Delete resources.

Step 1 – Create the Baseline Pipeline

We’ll set up an S3 bucket for uploads, a DynamoDB table for storage, a Lambda function to connect them, and a customer-managed KMS key for encryption testing later.

Create the bucket, table, and CMK

aws s3api create-bucket \
  --bucket s3-kms-demo \
  --region us-east-1
aws dynamodb create-table \
  --table-name kms-demo-table \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST
aws kms create-key \
  --description "KMS demo key" \
  --query KeyMetadata.KeyId \
  --output text

✅ Database and CMK are live.
Next, build the Lambda handler.

Create the Lambda handler

import boto3, hashlib, json

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('kms-demo-table')

def lambda_handler(event, context):
    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        key = record['s3']['object']['key']
        s3 = boto3.client('s3')
        obj = s3.get_object(Bucket=bucket, Key=key)
        data = obj['Body'].read().decode('utf-8')
        item_id = hashlib.md5(key.encode()).hexdigest()
        table.put_item(Item={'id': item_id, 'filename': key, 'content': data})
    print("All records processed successfully.")

Next, package and deploy with least-privilege access.

Package and deploy the Lambda function

zip function.zip lambda_handler.py
aws iam create-role \
  --role-name kms-demo-role \
  --assume-role-policy-document file://trust-policy.json
aws iam put-role-policy \
  --role-name kms-demo-role \
  --policy-name kms-demo-policy \
  --policy-document file://inline-policy.json
aws iam attach-role-policy \
  --role-name kms-demo-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws lambda create-function \
  --function-name kms-demo-func \
  --runtime python3.9 \
  --role arn:aws:iam::<account-id>:role/kms-demo-role \
  --handler lambda_handler.lambda_handler \
  --zip-file fileb://function.zip

Expected output:

{ "FunctionName": "kms-demo-func", "State": "Active" }

✅ Lambda is live with tight permissions and proper CloudWatch logging.
Next, wire S3 to Lambda.

Connect S3 to Lambda

aws lambda add-permission \
  --function-name kms-demo-func \
  --principal s3.amazonaws.com \
  --statement-id s3invoke \
  --action "lambda:InvokeFunction" \
  --source-arn arn:aws:s3:::s3-kms-demo
aws s3api put-bucket-notification-configuration \
  --bucket s3-kms-demo \
  --notification-configuration '{"LambdaFunctionConfigurations":[{"LambdaFunctionArn":"arn:aws:lambda:us-east-1:<account-id>:function:kms-demo-func","Events":["s3:ObjectCreated:*"]}]}'

✅ Pipeline connected.
Next, verify that it works.


Step 2 – Test the Pipeline

echo "hello world" > file1.txt
aws s3 cp file1.txt s3://s3-kms-demo/
aws dynamodb scan \
  --table-name kms-demo-table

Expected output:

{
  "Items": [
    { "filename": {"S":"file1.txt"}, "content": {"S":"hello world"} }
  ],
  "Count": 1
}

✅ Data flows correctly.
Next, we’ll break it.


Step 3 – Introduce the Failure

KEY_ID=$(aws kms create-key \
  --description "Demo encryption key" \
  --query KeyMetadata.KeyId \
  --output text)
aws s3api put-bucket-encryption \
  --bucket s3-kms-demo \
  --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"'$KEY_ID'"}}]}'
echo "should fail" > locked.txt
aws s3 cp locked.txt s3://s3-kms-demo/

✅ We’ve created an AccessDenied condition.
Next, verify it.


Step 4 – Observe the Failure

aws logs filter-log-events \
  --log-group-name /aws/lambda/kms-demo-func \
  --filter-pattern "AccessDenied" \
  --limit 1

Expected output:

botocore.exceptions.ClientError: An error occurred (AccessDenied) when calling the GetObject operation: Access Denied

The event failed silently.

Next, add a DLQ while the pipeline is still broken.


Step 5 – Add and Test a DLQ While Still Broken

aws sqs create-queue \
  --queue-name kms-demo-dlq
QUEUE_ARN=$(aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/<account-id>/kms-demo-dlq \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' \
  --output text)
aws lambda update-function-configuration \
  --function-name kms-demo-func \
  --dead-letter-config TargetArn=$QUEUE_ARN
echo "dlq-test" > dlq.txt
aws s3 cp dlq.txt s3://s3-kms-demo/
aws sqs receive-message \
  --queue-url https://sqs.us-east-1.amazonaws.com/<account-id>/kms-demo-dlq

✅ The DLQ successfully caught the event.
Next, fix the permissions on our CMK and bucket.


Step 6 – Apply the Fix

aws kms put-key-policy \
  --key-id $KEY_ID \
  --policy-name default \
  --policy file://kms-policy.json
aws s3api put-bucket-policy \
  --bucket s3-kms-demo \
  --policy file://bucket-policy.json

✅ Lambda can now read encrypted objects.\

Here are the two policy files referenced above — add them before re-running the commands for a complete, reproducible setup.

kms-policy.json

{
  "Version": "2012-10-17",
  "Id": "kms-demo-policy",
  "Statement": [
    {
      "Sid": "EnableRootPermissions",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::<account-id>:root" },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AllowLambdaDecrypt",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::<account-id>:role/kms-demo-role" },
      "Action": ["kms:Decrypt", "kms:DescribeKey"],
      "Resource": "*"
    }
  ]
}

bucket-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowLambdaGetObject",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::<account-id>:role/kms-demo-role" },
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::s3-kms-demo/*"]
    }
  ]
}

Next, replay the failed events from the DLQ.


Step 7 – Replay and Verify Recovery

import boto3, json

lambda_client = boto3.client('lambda')
sqs = boto3.client('sqs')
queue_url = 'https://sqs.us-east-1.amazonaws.com/<account-id>/kms-demo-dlq'

while True:
    messages = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10)
    if 'Messages' not in messages:
        break
    for msg in messages['Messages']:
        payload = json.loads(msg['Body'])
        lambda_client.invoke(FunctionName='kms-demo-func', InvocationType='Event', Payload=json.dumps(payload))
        sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg['ReceiptHandle'])
        print("Replayed message:", msg['MessageId'])
aws sqs receive-message \
  --queue-url https://sqs.us-east-1.amazonaws.com/<account-id>/kms-demo-dlq
aws dynamodb scan \
  --table-name kms-demo-table

Expected output:

{ "Count": 2 }

✅ Replay succeeded.
Next, confirm normal operation.


Step 8 – Re-Test and Confirm

echo "fixed" > fixed.txt
aws s3 cp fixed.txt s3://s3-kms-demo/
aws dynamodb scan \
  --table-name kms-demo-table

Expected output:

{ "Count": 3 }

✅ Everything works again.
Next, clean it all up.


Step 9 – Delete Resources

aws lambda delete-function \
  --function-name kms-demo-func
aws s3 rb s3://s3-kms-demo \
  --force
aws dynamodb delete-table \
  --table-name kms-demo-table
aws kms schedule-key-deletion \
  --key-id $KEY_ID \
  --pending-window-in-days 7

Expected output:

{ "ResponseMetadata": { "HTTPStatusCode": 200 } }

✅ Environment cleared — no leftover costs.


Pro Tips

  • Add your DLQ before fixing the pipeline —you can’t replay what you didn’t catch.
  • Use customer-managed CMKs for controlled demos; AWS-managed keys can’t be re-policy-ed.
  • Keep IAM policies least-privilege for tighter examples.
  • Automate DLQ replays for production durability.
  • Replays must be idempotent — no duplicates, no overwrites. Using PutItem with a guaranteed unique key (like an MD5 of the S3 object key) makes this automatic.

Conclusion

Encryption isn’t the enemy — invisibility is.

When KMS policies silently block your Lambda reads, the danger isn’t the failure itself — it’s not knowing it happened.

The combination of DLQs, CMKs, and clear IAM boundaries turns that silence into signal.

You see the failure, you capture it, you fix it, and you replay it — no drift, no guesswork.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Comments

Popular posts from this blog

The New ChatGPT Reason Feature: What It Is and Why You Should Use It

Insight: The Great Minimal OS Showdown—DietPi vs Raspberry Pi OS Lite

Raspberry Pi Connect vs. RealVNC: A Comprehensive Comparison