paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

August 21, 2019

AWS IAM Basics: Users, Groups, and Permissions

A Practical Guide to Identity and Access Management for AWS Engineers

Introduction

AWS Identity and Access Management (IAM) is the control plane that decides who can do what inside an AWS account. Every API call, console click, and CLI command passes through IAM's evaluation logic before it is allowed or denied. Because IAM sits underneath every other AWS service, a misunderstanding here does not stay contained - it propagates into S3 buckets left open, Lambda functions with excessive permissions, and EC2 instances that can reach far more of the account than they should. Engineers who treat IAM as a checkbox during setup, rather than a system to design deliberately, tend to discover its importance only after an incident.

This article works through IAM's three foundational building blocks - users, groups, and permissions - before extending into roles, policy evaluation, and the practical patterns that production teams actually use. Rather than repeating the AWS documentation in different words, the goal is to explain the reasoning behind IAM's design: why groups exist, why roles are usually better than users for workloads, and why the evaluation engine is built around explicit denial rather than simple allow lists. Code examples in TypeScript and Python illustrate patterns you can adapt directly, and the later sections cover trade-offs and pitfalls that are easy to miss until they cause a problem.

The Problem: Why IAM Complexity Grows

Most AWS accounts start simple. A single engineer or a small team creates a handful of IAM users, attaches a broad managed policy like AdministratorAccess to move quickly, and worries about tightening things up later. This works fine at small scale because there is little to misconfigure - everyone can see everyone else's access, and the blast radius of a mistake is limited by the size of the team itself.

The trouble starts as the account grows. New services get added, new teams onboard, and permissions get layered on rather than redesigned. An engineer joins the data team and inherits a policy written for someone who left eight months ago. A CI/CD pipeline gets an access key because "it was faster than setting up a role," and that key ends up hardcoded in a repository. A one-off script uses s3:* on * because narrowing the resource ARN felt unnecessary at the time. None of these decisions look dangerous in isolation, but they accumulate into a permission graph that nobody fully understands.

The consequence is that access control drifts away from actual need. Security reviews turn into forensic exercises, incident response is slower because the blast radius of any single credential is unclear, and compliance audits (SOC 2, ISO 27001, PCI-DSS) surface the gap between documented policy and real-world configuration. IAM's structure - users, groups, roles, and policies working together - exists specifically to prevent this kind of drift, but only if it is applied with intent rather than treated as an afterthought.

IAM Users: The Building Block of Identity

An IAM user represents a persistent identity within an AWS account - typically a person, though occasionally a legacy application. Each user has a unique Amazon Resource Name (ARN) and can be issued one or more forms of long-term credentials: a console password for interactive sign-in, and access keys for programmatic use through the CLI or SDKs. Multi-factor authentication (MFA) can and should be attached to any user with console access, since a password alone is a single point of failure for account compromise.

The practical guidance around IAM users has narrowed considerably over the years. AWS now explicitly recommends against creating IAM users for workloads such as applications, CI/CD pipelines, or EC2 instances, because long-lived access keys are difficult to rotate reliably and become high-value targets if leaked into logs, source control, or build artifacts. Users still make sense for human operators who need durable, individually attributable identities, but even then the emphasis has shifted toward federating human access through IAM Identity Center (the successor to AWS SSO) rather than provisioning IAM users one at a time. The root user, meanwhile, should be used only for the small set of actions that require it - such as closing an account or changing a support plan - and never for daily operations.

IAM Groups: Scaling Permission Management

An IAM group is a named collection of users that share a set of policies. Groups are not principals themselves - a group cannot be assumed by a service and cannot appear in a policy's Principal element - but they are an efficient way to manage permissions for people performing similar functions. Attaching a policy to a group of twenty developers is a single operation; attaching the same policy to twenty individual users, and remembering to repeat that whenever the policy changes, is not.

A common structure separates an organization into functional groups: Developers might receive permissions scoped to a development AWS account or a specific set of services, DBAdmins might receive RDS and backup-related permissions, and SysAdmins might receive broader infrastructure access. When someone joins the database team, adding them to DBAdmins grants the correct baseline immediately, and removing them from the group on departure or role change revokes it just as cleanly. This is significantly more auditable than tracking which individual policies were attached to which individual user over time.

Groups have real limits worth knowing before you rely on them. IAM does not support nested groups - a group cannot contain another group - and a single user can belong to a maximum of ten groups by default, a soft limit that can be raised but rarely needs to be. Groups also don't replace the need for periodic review: it's easy for a group's policy to grow more permissive over time as one-off requests get folded in "temporarily." Treating group policies with the same change-review discipline as application code - pull requests, approvals, and periodic pruning - keeps that from happening silently.

Understanding IAM Policies and Permissions

Policies are the actual mechanism that grants or denies access; users, groups, and roles are just the entities policies attach to. A policy is a JSON document with a small, fixed vocabulary: Version pins the policy language version (almost always 2012-10-17), and Statement is an array of individual permission statements, each with an Effect (Allow or Deny), one or more Actions, one or more Resource ARNs, and an optional Condition block that narrows when the statement applies.

The following policy grants read-only access to objects in a specific S3 bucket, restricted further so it only applies when the request originates from a specific corporate IP range:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::example-bucket/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "203.0.113.0/24"
        }
      }
    }
  ]
}

The Condition block is where IAM policies go from coarse to precise. Beyond IP restriction, common condition keys include aws:MultiFactorAuthPresent (require an active MFA session), aws:RequestedRegion (restrict to approved regions), and tag-based keys like aws:ResourceTag that enable attribute-based access control (ABAC) - granting access based on matching tags between a principal and a resource rather than hardcoding individual ARNs.

Policies also come in different flavors that matter for how you manage them at scale. AWS managed policies (like AmazonS3ReadOnlyAccess) are maintained by AWS and updated automatically as services evolve; customer managed policies are versions you write and own; inline policies are embedded directly in a single user, group, or role rather than existing as a standalone, reusable object. Managed policies are generally preferred because they can be attached to multiple identities and audited centrally, while inline policies tend to accumulate as one-off exceptions that are easy to lose track of.

Finally, it's worth understanding how AWS combines multiple applicable policies into a single decision, because this is where most confusing "why can't I access this" tickets originate. The evaluation logic is: start from an implicit deny, gather every policy that applies (identity-based policies on the user/group/role, resource-based policies on the target resource, permission boundaries, and any Service Control Policies from AWS Organizations), and check for an explicit Deny anywhere in that set - if one exists, the request is denied regardless of any Allow. If no explicit deny is found and at least one applicable statement allows the action, the request succeeds; otherwise it falls back to the implicit deny. Permission boundaries and SCPs don't grant access on their own - they cap the maximum permissions an identity-based policy can actually exercise, which is a distinction that trips up a lot of people who expect them to work like an additional grant.

Roles vs. Users: When to Use Which

An IAM role is similar to a user in that it can hold permissions policies, but it has no long-term credentials of its own and cannot be logged into directly. Instead, a role is assumed - a trusted entity (an AWS service, an IAM user, an external identity provider, or an account in a different AWS Organization) calls AWS Security Token Service (STS) to receive temporary credentials, typically valid for anywhere from fifteen minutes to twelve hours. Every EC2 instance profile, every Lambda execution role, and every cross-account access pattern in a well-designed AWS environment uses this mechanism rather than embedded access keys.

The practical decision between a user and a role usually comes down to who or what is doing the accessing and how long that access needs to persist. A role is the right choice whenever a workload - a Lambda function, an ECS task, a CI/CD runner, a data pipeline - needs to call AWS APIs, because the temporary credentials it receives are automatically rotated and scoped to that specific execution context, with no secret to leak in the first place. A user still makes sense for a human who needs durable, individually-attributable access, though even there, federating through an identity provider into a role is often preferable to a standing IAM user, since it centralizes authentication and avoids managing passwords and access keys per account.

Practical Implementation: A Worked Example

Consider a Lambda function that reads incoming order files from an S3 bucket and writes processed records into a DynamoDB table. The naive approach attaches a managed policy like AmazonS3FullAccess and AmazonDynamoDBFullAccess to the function's execution role because it's fast and "will definitely work." The least-privilege approach scopes the role to exactly the actions and resources the function needs, defined as infrastructure-as-code so the permissions are reviewable and version-controlled alongside the application. Here's what that looks like using the AWS Cloud Development Kit (CDK) in TypeScript:

import { Stack, StackProps } from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';

export class OrderProcessorStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const ordersBucket = s3.Bucket.fromBucketName(this, 'OrdersBucket', 'orders-raw-data');
    const ordersTable = dynamodb.Table.fromTableName(this, 'OrdersTable', 'orders');

    const executionRole = new iam.Role(this, 'OrderProcessorExecutionRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      description: 'Execution role for the order processor function, scoped to one bucket prefix and one table',
    });

    executionRole.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject'],
      resources: [`${ordersBucket.bucketArn}/incoming/*`],
    }));

    executionRole.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['dynamodb:PutItem', 'dynamodb:UpdateItem'],
      resources: [ordersTable.tableArn],
    }));

    executionRole.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
    );

    new lambda.Function(this, 'OrderProcessorFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/order-processor'),
      role: executionRole,
    });
  }
}

Notice what this code does not do: it never grants s3:* or uses a bucket-wide wildcard resource, and it never touches DynamoDB tables outside the one it needs. The role's trust policy (assumedBy) also restricts who can assume it to the Lambda service itself, so even if the role's ARN were discovered, nothing else could use it without also being an authorized Lambda execution context. This is the pattern worth internalizing: permissions are expressed as code, reviewed the same way application logic is reviewed, and scoped down to specific ARNs rather than left broad "to be safe."

The same discipline applies to human-facing configuration, and it's just as easy to express in code. The following Python example using boto3 creates a group that enforces MFA before allowing any action other than setting up MFA itself - a well-established pattern for protecting accounts against credential theft:

import boto3
import json

iam = boto3.client("iam")

mfa_enforcement_policy = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowViewAccountInfo",
            "Effect": "Allow",
            "Action": ["iam:GetAccountSummary", "iam:ListVirtualMFADevices"],
            "Resource": "*",
        },
        {
            "Sid": "DenyMostActionsWithoutMFA",
            "Effect": "Deny",
            "NotAction": [
                "iam:CreateVirtualMFADevice",
                "iam:EnableMFADevice",
                "iam:ListMFADevices",
                "iam:ResyncMFADevice",
                "sts:GetSessionToken",
            ],
            "Resource": "*",
            "Condition": {
                "BoolIfExists": {"aws:MultiFactorAuthPresent": "false"}
            },
        },
    ],
}

policy = iam.create_policy(
    PolicyName="RequireMFA",
    PolicyDocument=json.dumps(mfa_enforcement_policy),
    Description="Denies all actions except MFA setup when the caller has not authenticated with MFA",
)

iam.create_group(GroupName="Developers")
iam.attach_group_policy(GroupName="Developers", PolicyArn=policy["Policy"]["Arn"])
iam.attach_group_policy(
    GroupName="Developers",
    PolicyArn="arn:aws:iam::aws:policy/PowerUserAccess",
)

iam.create_user(UserName="jane.doe")
iam.add_user_to_group(GroupName="Developers", UserName="jane.doe")

The NotAction and Condition combination is the key idea: instead of listing every action a developer might need and re-adding MFA checks to each one, the policy denies everything except a short allow-list whenever aws:MultiFactorAuthPresent is false. Any new developer added to this group inherits the MFA requirement automatically, without anyone needing to remember to configure it per-user.

Trade-offs and Common Pitfalls

Least privilege is easy to state as a principle and genuinely hard to execute continuously, because it's in direct tension with development velocity. Scoping a policy down to exact resource ARNs and actions requires knowing in advance exactly what a workload will need, which is rarely true during initial development. Teams often start broad "to unblock the sprint" with a plan to tighten later, and that plan competes for attention with every other backlog item - which is precisely how wildcard permissions become permanent. Attribute-based access control has a similar trade-off: tag-driven policies scale better than maintaining per-resource ARNs, but they only work if tagging discipline is enforced everywhere, and a single untagged or mistagged resource silently falls outside the intended access model.

A handful of specific pitfalls show up repeatedly in IAM reviews. Unused IAM users and unrotated access keys accumulate quietly, especially for former employees or decommissioned integrations, and each one is a live credential nobody is watching. Group-based permissioning, useful as it is, can also over-grant: adding someone to a group for one needed permission means they inherit everything else attached to that group too, which is why overly broad "catch-all" groups tend to defeat the purpose of grouping in the first place. And the explicit-deny-always-wins evaluation rule, while important for safety, is also a common source of confusing outages - an SCP or permission boundary applied at the organization level can silently block access that looks correctly configured at the account level, and tracing that requires checking every layer, not just the identity's own policy.

Best Practices for Production-Grade IAM

A small set of practices accounts for most of the risk reduction available from IAM, and they're worth treating as non-negotiable rather than aspirational. Enforce MFA for every human identity with console access, without exception. Prefer roles over long-lived access keys for every workload - Lambda, ECS, EC2, CI/CD runners - since temporary credentials remove an entire category of leaked-secret incidents. Rotate anything that can't be replaced with a role, and use IAM Access Analyzer to identify permissions that are granted but never actually used, which it does by examining CloudTrail activity and suggesting a tighter policy based on real usage rather than guesswork.

At the organizational level, IAM Identity Center (the successor to AWS SSO) is the recommended way to manage human access across multiple AWS accounts, since it centralizes authentication against a single identity source - whether that's its built-in directory or an external provider like Okta or Azure AD - rather than provisioning separate IAM users per account. Service Control Policies at the AWS Organizations level provide a backstop that individual account administrators can't override, useful for account-wide guardrails like disallowing specific regions or preventing the disabling of CloudTrail.

None of this holds up without treating permissions as something that's reviewed on a cadence, not configured once and forgotten. Defining IAM roles and policies as code - through the CDK, Terraform, or CloudFormation - means changes go through the same pull request and review process as application code, which catches overly broad grants before they ship rather than during a post-incident review. Pairing that with continuous monitoring - CloudTrail for the audit log of every API call, and GuardDuty for anomaly detection against unusual credential use - closes the loop between "we wrote a tight policy" and "we'd actually notice if something used a credential in a way it shouldn't."

Analogies & Mental Models

A physical-access analogy makes IAM's structure easier to hold in your head. An IAM user is like an employee's personal badge - it's tied to one identity, it works until it's deactivated, and if it's lost or copied, whoever holds it has that employee's access until someone notices and revokes it. A group is like a department-wide badge configuration - everyone on the finance team gets access to the finance floor automatically, and updating what "finance floor access" means updates it for everyone at once rather than requiring a visit to each badge individually. A role is closer to a visitor badge issued at the front desk: it's created on the spot, it expires automatically at the end of the day, and it never needs to be tracked down and physically collected when the visit is over - it just stops working.

Policy evaluation maps cleanly onto a courtroom metaphor. The default posture is a presumption of no access, the same way a courtroom presumes innocence until evidence says otherwise - nothing is permitted until some policy explicitly allows it. An Allow statement is like evidence presented in favor of the defendant: necessary, but not automatically decisive. An explicit Deny, by contrast, functions like a judge's binding ruling - once it's on the record, no amount of favorable evidence introduced elsewhere in the trial can overturn it. Keeping that asymmetry in mind - allows are necessary but contestable, denies are absolute - resolves most of the confusion people run into when multiple policies apply to the same request.

The 80/20 of IAM

If you strip IAM down to the handful of practices that produce most of the actual security benefit, three stand out clearly above the rest. Eliminating routine use of the root user and enforcing MFA everywhere closes off the two most catastrophic single points of failure - a compromised root credential or an unprotected password is close to a full account takeover. Replacing long-lived access keys with roles for every workload removes the most common source of real-world leaked-credential incidents, since keys committed to source control or logged accidentally are a recurring, well-documented failure mode.

The second tier - organizing permissions around groups and roles rather than individually per user, and running IAM Access Analyzer on a schedule rather than only during an incident - is where most of the remaining, easily-captured value lives. Access Analyzer in particular is worth calling out specifically: it's a free, built-in tool that looks at actual CloudTrail usage and flags exactly which granted permissions were never exercised, which turns "we should tighten this policy sometime" into a concrete, actionable list.

Everything past that - fine-grained ABAC with tag-based conditions, Service Control Policies tuned per organizational unit, permission boundaries scoped to delegated administrators - is real and valuable, but it's also where diminishing returns set in. These tools solve specific problems at meaningful scale (large multi-team organizations, regulated environments, delegated account creation), and reaching for them before the fundamentals are solid tends to add operational complexity without a proportional security gain. Get MFA, root-user discipline, and role-based workload access right first; everything else compounds on top of that foundation.

Key Takeaways

For a team auditing or setting up IAM today, these five steps capture most of the value discussed above and can be implemented incrementally without a full redesign:

Conclusion

IAM's model of users, groups, roles, and policies is not complicated in isolation - the difficulty is almost entirely in applying it consistently as an account grows and more people touch it. Users represent identity, groups make managing similar humans tractable, roles remove the need for long-lived credentials on workloads, and policies provide the precise, auditable language for expressing exactly what any of them can do. Understanding how these pieces combine, and specifically how the evaluation engine treats explicit denials as absolute, resolves the majority of confusing access issues before they turn into support tickets or incidents.

The teams that keep IAM under control over time are rarely the ones with the most sophisticated policies - they're the ones that treat permissions as something reviewed continuously, expressed as code, and tightened based on actual usage data rather than intuition. Starting with MFA, role-based workload access, and IAM Access Analyzer covers most of the risk with modest effort; layering in ABAC, SCPs, and permission boundaries makes sense once that foundation is solid, not before. Whichever stage your account is at, the AWS documentation linked below is worth reading directly rather than secondhand, since IAM's evaluation semantics have enough edge cases that precision matters.

References

A quick flashcard preview - one example of each question type in this post. This is a demo of what you'll find in the full quiz app.

Flashcard preview - 1 / 9Multiple Choice

multiple choice - advanced - auto-graded

During policy evaluation, an identity has one policy statement with Effect: Allow for an action, and a separate policy statement with Effect: Deny for the same action. What is the outcome?

Choose an answer