Least-Privilege AWS IAM for Small Teams: Permission Boundaries, Access Analyzer, and Terraform
Small teams hit the same IAM wall. Either one person holds admin and becomes the bottleneck for every new Lambda role, or everyone gets AdministratorAccess "for now" and it never gets taken back. Neither scales. Permiss
Small teams hit the same IAM wall. Either one person holds admin and becomes the bottleneck for every new Lambda role, or everyone gets AdministratorAccess "for now" and it never gets taken back. Neither scales.
Permission boundaries offer a middle path: developers can create and manage their own roles, but no role they create can ever exceed a ceiling you define. Pair that with IAM Access Analyzer to generate policies from real usage and flag unused access, and you get least privilege without a dedicated security team.
How permission boundaries work
A permission boundary is a managed policy attached to a user or role that sets the maximum permissions it can have. Effective permissions are the intersection:
effective = identity policies รขหยฉ permission boundary (minus any explicit deny, SCPs, etc.)
A boundary grants nothing by itself. If the boundary allows s3:* but the role's policy allows only s3:GetObject, the role gets s3:GetObject. If the role's policy allows iam:* but the boundary doesn't, the role gets no IAM access.
The useful pattern: let developers call iam:CreateRole only if they attach the boundary. Then even a role with AdministratorAccess attached is capped.
Step 1: The boundary policy
This boundary allows typical application services, blocks privilege escalation, and prevents anyone from removing or editing the boundary itself. Replace 123456789012 with your account ID.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAppServices",
"Effect": "Allow",
"Action": [
"s3:*",
"dynamodb:*",
"sqs:*",
"sns:*",
"lambda:*",
"logs:*",
"cloudwatch:*",
"xray:*",
"events:*",
"states:*",
"ssm:GetParameter*",
"secretsmanager:GetSecretValue",
"kms:Decrypt",
"kms:GenerateDataKey*"
],
"Resource": "*"
},
{
"Sid": "AllowRoleManagementOnlyWithBoundary",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:PutRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:DeleteRolePolicy"
],
"Resource": "arn:aws:iam::123456789012:role/app/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/app-boundary"
}
}
},
{
"Sid": "AllowRoleHousekeeping",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:TagRole",
"iam:UntagRole",
"iam:DeleteRole",
"iam:UpdateAssumeRolePolicy",
"iam:PassRole"
],
"Resource": "arn:aws:iam::123456789012:role/app/*"
},
{
"Sid": "DenyBoundaryTampering",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": "arn:aws:iam::123456789012:policy/app-boundary"
},
{
"Sid": "DenyBoundaryRemoval",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary"
],
"Resource": "*"
},
{
"Sid": "DenyUserAndKeyCreation",
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:CreateAccessKey",
"iam:CreateLoginProfile",
"organizations:*",
"account:*"
],
"Resource": "*"
}
]
}
Key choices:
-
Path scoping (
role/app/*). Developer-created roles live under the/app/path, so they can't touch your break-glass or CI roles elsewhere. -
iam:PermissionsBoundarycondition.CreateRole,PutRolePolicy, andAttachRolePolicysucceed only when the target role carries this exact boundary. That condition key is supported on those actions. -
iam:PassRolelimited to/app/. Developers can hand their roles to Lambda or ECS, but can't pass a more powerful role. - Explicit denies stop anyone from rewriting or detaching the boundary.
Apply the same boundary to the developers' own role. That way the developer, and every role the developer creates, sits under the same ceiling. Without it, a developer could create a role without the boundary through some other permission they hold.
Step 2: Terraform for the boundary and a bounded role
data "aws_caller_identity" "current" {}
resource "aws_iam_policy" "app_boundary" {
name = "app-boundary"
description = "Permission ceiling for developer-managed application roles"
policy = templatefile("${path.module}/app-boundary.json.tftpl", {
account_id = data.aws_caller_identity.current.account_id
})
}
data "aws_iam_policy_document" "lambda_trust" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "orders_api" {
name = "orders-api"
path = "/app/"
assume_role_policy = data.aws_iam_policy_document.lambda_trust.json
permissions_boundary = aws_iam_policy.app_boundary.arn
}
data "aws_iam_policy_document" "orders_api" {
statement {
sid = "OrdersTable"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"]
resources = ["arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/orders"]
}
statement {
sid = "Logs"
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/orders-api:*"]
}
}
resource "aws_iam_role_policy" "orders_api" {
name = "orders-api"
role = aws_iam_role.orders_api.id
policy = data.aws_iam_policy_document.orders_api.json
}
Save the boundary JSON from Step 1 as app-boundary.json.tftpl, replacing 123456789012 with ${account_id}. The templatefile call fills it in.
The role's own policy is narrow; the boundary is the safety net if someone later attaches something broad. To make the boundary hard to forget, wrap the role in a small module where permissions_boundary and path are fixed and not variables:
# modules/app-role/main.tf
variable "name" { type = string }
variable "trust_policy_json" { type = string }
variable "boundary_arn" { type = string }
resource "aws_iam_role" "this" {
name = var.name
path = "/app/"
assume_role_policy = var.trust_policy_json
permissions_boundary = var.boundary_arn
}
output "role_arn" { value = aws_iam_role.this.arn }
output "role_name" { value = aws_iam_role.this.name }
Proving the boundary works
Assume the developer role and try to create a role without the boundary:
aws iam create-role \
--role-name test-no-boundary \
--path /app/ \
--assume-role-policy-document file://lambda-trust.json
# An error occurred (AccessDenied) ... not authorized to perform: iam:CreateRole
Then retry with --permissions-boundary arn:aws:iam::123456789012:policy/app-boundary and it succeeds. Put this check in your onboarding docs; it is the fastest way to show the team that the guardrail is real.
Step 3: Generate policies from real usage with Access Analyzer
Writing narrow policies by hand is slow. IAM Access Analyzer can read CloudTrail and generate a policy based on what a role actually called. You need a CloudTrail trail (management events) logging to S3, and a service role Access Analyzer can use to read it.
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/app/orders-api"}' \
--cloud-trail-details '{
"trails":[{"cloudTrailArn":"arn:aws:cloudtrail:us-east-1:123456789012:trail/management","allRegions":true}],
"accessRole":"arn:aws:iam::123456789012:role/service-role/AccessAnalyzerMonitorServiceRole",
"startTime":"2026-09-15T00:00:00Z",
"endTime":"2026-10-15T00:00:00Z"
}'
# returns a jobId
aws accessanalyzer get-generated-policy \
--job-id <jobId> \
--include-resource-placeholders
The output is a draft policy listing the actions used. Treat it as a starting point:
- It captures management events by default. Data events such as
s3:GetObjectanddynamodb:GetItemappear only if your trail logs them, so add those by hand if needed. - Use
--include-resource-placeholdersand replace placeholders with real ARNs; a generated"Resource": "*"is not least privilege. - Use a window that covers the role's full cycle, including monthly jobs, or you'll strip permissions that run rarely.
Before applying any edited policy, validate it:
aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY \
--policy-document file://orders-api.json
This catches errors, security warnings (like iam:PassRole with *), and suggestions. You can run it in CI against terraform show -json output so bad policies fail review.
Step 4: Unused-access findings
Policy generation is per role and on demand. Unused-access analyzers run continuously across the account and report:
- roles not used within the tracking period,
- unused access keys and passwords,
- permissions granted but not used (at service and action level).
Create one in Terraform:
resource "aws_accessanalyzer_analyzer" "unused" {
analyzer_name = "unused-access"
type = "ACCOUNT_UNUSED_ACCESS"
configuration {
unused_access {
unused_access_age = 90
}
}
}
Unused-access analyzers are a paid feature, billed per IAM role and user analyzed per month, so check current pricing before enabling. For a small account the cost is usually modest. External-access analyzers (type = "ACCOUNT") are free and worth enabling too, since they flag resources shared outside your account.
List active findings:
aws accessanalyzer list-findings-v2 \
--analyzer-arn arn:aws:access-analyzer:us-east-1:123456789012:analyzer/unused-access \
--filter '{"status":{"eq":["ACTIVE"]}}'
Review them in a monthly slot. Delete unused roles, remove unused services from policies, and archive findings that are deliberate (for example, disaster-recovery roles that sit idle by design) with an archive rule so they don't keep resurfacing.
A least-privilege checklist for small teams
- [ ] Humans sign in through IAM Identity Center; no IAM users with long-lived keys.
- [ ] A permission boundary exists and is attached to developer roles and every role they create.
- [ ] Developer-managed roles live under a dedicated path such as
/app/. - [ ]
iam:PassRoleis scoped to that path, never*. - [ ] The boundary denies editing or removing itself.
- [ ] Application roles are created through a Terraform module that fixes the boundary.
- [ ] New role policies start from Access Analyzer policy generation, then get real resource ARNs.
- [ ]
validate-policyruns in CI on IAM changes. - [ ] An external-access analyzer is enabled in every region you use.
- [ ] An unused-access analyzer is enabled and findings are reviewed monthly.
- [ ] Break-glass admin access exists, is documented, and alerts when used.
Wrap-up
Permission boundaries let a small team move fast without one person approving every role, and Access Analyzer turns "least privilege" from a guess into something based on actual usage. Start with the boundary, since it caps the blast radius immediately, then tighten individual roles over time as the findings come in.
Written by the ByteBridge Talent LLC software engineering team.
ByteBridge Talent LLC is an E-Verify enrolled tech staffing firm in Grand Island, Nebraska: bytebridgetalent.com
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.