Secure Serverless Functions with IAM
Secure serverless functions with IAM in this hands-on tutorial. Learn least privilege, identity-based policies, and execution roles to protect AWS Lambda and similar services. Practical exercise included.
Focus: secure serverless functions with iam
Your serverless function might be one Action: "s3:*" statement away from becoming an open back door. When developers first ship Lambda functions, API Gateway endpoints, or Cloud Functions, they often copy-paste the most permissive IAM role they can find — and that habit is exactly how a single compromised dependency becomes a full account takeover. This lesson shows you how to secure serverless functions with IAM using the least privilege principle, identity-based vs. resource-based policies, execution roles, and policy conditions — so your functions can do their job and nothing else.
The problem this lesson solves
Serverless computing removes servers, but it does not remove identity. Every invocation of your function runs under an execution role — an IAM identity that carries permissions. The moment an attacker injects a malicious input or exploits a vulnerable library inside your function, they inherit every permission that role has. If your role can delete an S3 bucket, the attacker can too. If it can assume a privileged role, they just escalated.
Typical pain points:
- Functions fail in production because the execution role is too narrow and you keep widening it — permission sprawl.
- Functions ship with
*permissions because "it works locally" — blast radius explosion. - You can't tell which function did what in CloudTrail because all functions share one role — audit nightmare.
Pro tip: The principle of least privilege is not a guideline; it's a hard security control. Every permission you grant is an attack surface.
Core concept / mental model
Think of IAM for serverless as a key card system for a building.
- The function is a person walking through the building.
- The execution role is the key card they wear.
- Identity-based policies are the doors that card can open.
- Resource-based policies are extra badges that let others into your room.
- Conditions are time locks — the card only works during business hours.
Your function is not the compute; it's the identity. The AWS Lambda service assumes the role on your function's behalf, and then all API calls are made with that role's context. If your function is arn:aws:lambda:us-east-1:123456789012:function:process_order, its identity is the role, not the function name.
Two policy types matter:
- Identity-based policies — attached to the execution role, define what your function can do.
- Resource-based policies — attached to resources like S3 buckets or SQS queues, define who else can invoke your function.
How it works step by step
- Create an execution role dedicated to your function. Never reuse the default
lambda_basic_executionrole for anything beyond logging. - Attach a minimal identity-based policy that lists only the actions and resources your function needs — e.g.,
s3:GetObjecton a specific bucket anddynamodb:PutItemon a specific table. - Add a resource-based policy to the resource itself (if needed) to allow only your function's role to access it, not the whole account.
- Apply conditions for extra context — like
aws:SourceArnto prevent confused deputy, oraws:PrincipalOrgIDto restrict to your org. - Test and monitor: Use CloudTrail and IAM Access Analyzer to detect overly permissive policies after deployment.
Here's a feedback loop you'll appreciate: every time your function needs a new permission, add it specifically, test, and commit the change — don't just add *.
Hands-on walkthrough
Let's secure a real function that reads from S3 and writes to DynamoDB. Follow along in your AWS account (or localstack if you prefer).
Step 1: Define the policy document
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSpecificS3Object",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::my-invoices-bucket/*"
},
{
"Sid": "WriteSpecificDynamoTable",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/invoices"
},
{
"Sid": "WriteLogs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "*"
}
]
}
Step 2: Create the role and attach the policy (AWS CLI)
# Create the trust policy for Lambda
export TRUST_POLICY='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam create-role \
--role-name invoice-processor-role \
--assume-role-policy-document "$TRUST_POLICY"
# Save the policy above to invoice-policy.json, then attach it
aws iam put-role-policy \
--role-name invoice-processor-role \
--policy-name invoice-processing-policy \
--policy-document file://invoice-policy.json
# Attach the AWS managed logging policy (or use the inline part)
aws iam attach-role-policy \
--role-name invoice-processor-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Step 3: Secure the resource side
Add a resource-based policy to the S3 bucket to allow only your function's role to read:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowOnlyInvoiceProcessor",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/invoice-processor-role"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-invoices-bucket/*",
"Condition": {
"StringEquals": {
"aws:SourceArn": "arn:aws:lambda:us-east-1:123456789012:function:invoice-processor"
}
}
}
]
}
Step 4: Deploy and test the function
import boto3
def lambda_handler(event, context):
s3 = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
bucket = event['bucket']
key = event['key']
# Get the object — this works because the role allows s3:GetObject on that bucket
obj = s3.get_object(Bucket=bucket, Key=key)
content = obj['Body'].read().decode('utf-8')
# Write a summary to DynamoDB — allowed on the invoices table only
table = dynamodb.Table('invoices')
table.put_item(Item={'invoice_id': key, 'content': content})
return {'statusCode': 200, 'body': 'Processed ' + key}
Expected output: The function processes the file and writes to DynamoDB. If you try to read from a bucket not listed in the policy, you get a ClientError: Access Denied — that's your least privilege working.
Compare options / when to choose what
| Approach | When to use | Pros | Cons |
|---|---|---|---|
| Inline policies on execution role | Simple functions with 1–2 resources | No extra files; quick to test | Hard to reuse; not versioned |
| Managed policies attached to role | Repeated across many functions | Reusable, versioned, audit-friendly | Slightly more setup |
| Resource-based policies only | Cross-account or service-to-service invocations | Fine-grained, no IAM role path | Harder to manage at scale; confusing for humans |
| Permission boundaries | Teams with delegated admin | Prevents privilege escalation beyond boundary | Requires careful planning |
Recommendation for most cases: Use a dedicated managed policy per service (e.g., ReadInvoicesS3, WriteInvoicesDynamoDB) and attach them to a role per function. For cross-account, always pair with resource-based policies and conditions.
Pro tip: Use
iam:PassRolerestrictions on your CI/CD to ensure only known roles can be assigned to functions — otherwise anyone who can update a function can attach a*role.
Troubleshooting & edge cases
AccessDeniedeven though your policy allows the action? Check if the resource side also has a bucket policy or a KMS key policy that denies. IAM can allow, but resource policies can still block.InvalidPrincipalIderror when cross-account? Make sure you've specified the full role ARN, not just the role name, and that the trust policy in the other account allows the principal.- Confused deputy vulnerability: Always add
aws:SourceArnoraws:SourceAccountto your resource-based policies when your function invokes other services (like SQS or SNS), otherwise another account might trick yours into processing fake events. Function is not authorized to perform: dynamodb:PutItemmeans you forgot to restrict the table resource — or you used*instead of the exact table ARN.- Too many role iterations — if you keep widening the policy, redesign the function to use smaller, isolated permissions instead of blanket
s3:*.
What you learned & what's next
By now you can explain the core idea behind securing serverless functions with IAM: each function runs under a least-privilege execution role, with identity-based policies for actions and resource-based policies for external access. You have also completed a practical exercise that creates a role, attaches a narrow policy, and secures the resource side with a condition.
You now know:
- The difference between identity and resource policies.
- How to create a dedicated execution role with least privilege.
- How to add
aws:SourceArnconditions to prevent confused deputy. - Why you should never use
*actions when a specific action will do.
Next lesson in this track: Designing IAM permission boundaries for your own team — where you'll learn to control the maximum permissions any role can have, giving you a safety net even when a developer makes a mistake.
Keep your serverless functions tight, your roles minimal, and your audit logs meaningful. You're building a security habit that will save you many painful incident post-mortems later.
Practice recap
Try this now: Create a new Lambda function that reads from one S3 bucket and writes to one DynamoDB table. Attach a policy that allows s3:GetObject on that bucket and dynamodb:PutItem on that table only. Then attempt to call s3:PutObject from the function and confirm it fails with AccessDenied. Next, add an aws:SourceArn condition to the bucket policy and verify cross-account access is blocked. You'll have a production-ready secure function by the end.
Common mistakes
- Using
Action: "*"in an execution role 'just to get it working' — this turns any vulnerability into full account access. - Sharing one broad execution role across multiple functions — you lose audit trail and over-privilege every function.
- Adding bucket/queue policies that allow anonymous access instead of tying access to a specific IAM role ARN.
- Forgetting the
aws:SourceArncondition in resource-based policies, leaving your function open to confused deputy attacks.
Variations
- Use IAM permission boundaries to cap the maximum permissions a developer can grant to a function role, ideal for multi-team accounts.
- Replace custom role management with infrastructure as code (Terraform/CloudFormation) to version and review policies automatically.
- For cross-account invocations, use event source mappings with IAM roles (SQS, Kinesis) instead of resource policies to keep a single source of truth.
Real-world use cases
- A payment processing Lambda on AWS that reads encrypted receipts from S3 and writes only to a specific DynamoDB table, with no other permissions.
- An image resizing Cloud Function on GCP that triggers on Cloud Storage object creation and uses an IAM role with only
storage.objectViewerandstorage.objectCreatorscoped to its input and output buckets. - A data stream processor on Azure Functions that pulls from Event Hubs using a managed identity with a scoped role assignment, preventing any accidental deletion of the hub.
Key takeaways
- Every serverless function runs under an execution role that defines its entire attack surface.
- Apply least privilege by granting only the exact actions on only the exact resources the function needs.
- Use conditions like
aws:SourceArnto guard against confused deputy and cross-account abuse. - Separate identity-based policies (what the role can do) from resource-based policies (who can access the resource).
- Review your roles regularly with IAM Access Analyzer and CloudTrail to catch over-permissioned policies.
- Tighten policies incrementally — start narrow, add only what breaks, and commit every change.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.