IAM and Security

Users, groups, roles and policies, the principle of least privilege, and a real IAM policy JSON example.

What IAM is

IAM (Identity and Access Management) is AWS's system for controlling who — or what — can do what, to which resources. Every single API call made against AWS is checked against IAM first, whether the caller is a person logged into the console, a script running the CLI, or an EC2 instance calling another AWS service on its own behalf. There is no AWS action that bypasses this check, including actions you take on your own resources.

Four kinds of objects work together:

  • Users — an identity for a person (or occasionally a long-lived application), with its own credentials: a console password, and/or a pair of access keys for CLI/SDK use.
  • Groups — a named collection of users, used to attach the same set of policies to many people at once (a Developers group, a Billing group) instead of repeating the attachment per user.
  • Roles — an identity with no long-term credentials of its own at all, assumed temporarily by a user, an AWS service (EC2, Lambda), or even another AWS account. Roles are exactly how an EC2 instance or a Lambda function is able to call other AWS services without a password or access key embedded anywhere in its code or configuration.
  • Policies — JSON documents that define the actual permissions: which actions are allowed or denied, on which resources, optionally under which conditions. A policy does nothing on its own — it only takes effect once attached to a user, group, or role.

Anatomy of a policy document

Every IAM policy is a JSON document with the same basic shape:

JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowListBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::my-app-uploads"
    },
    {
      "Sid": "AllowReadObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-app-uploads/*"
    }
  ]
}
  • Version — the policy language version, always the fixed literal string "2012-10-17" in every policy you'll write today (an older 2008-10-17 format exists only for historical compatibility).
  • Statement — an array of individual permission rules; a policy can (and often does) contain many.
  • Effect — either "Allow" or "Deny". By default, everything is implicitly denied; an Allow statement is what grants access. An explicit Deny always wins over any Allow, anywhere else in any policy attached to the same identity.
  • Action — which API operations the statement covers, written as service:ActionName (s3:GetObject, ec2:StartInstances, rds:CreateDBSnapshot). Wildcards are allowed (s3:Get*, or s3:* for every S3 action), but a wildcard is exactly the kind of over-broad grant least privilege exists to avoid.
  • Resource — which specific resource(s) the actions apply to, identified by ARN (Amazon Resource Name). Notice the example above: s3:ListBucket targets the bucket ARN (arn:aws:s3:::my-app-uploads), while s3:GetObject targets object ARNs inside it (arn:aws:s3:::my-app-uploads/*) — a genuinely common source of confusion, since listing a bucket's contents and reading an object inside it are different permissions checked against different ARN shapes.
  • Condition (not shown above, optional) — narrows a statement further, e.g. only allowing an action from a specific IP range or only during a specific time window.

The principle of least privilege

Least privilege means granting exactly the permissions an identity needs to do its job, and nothing more. It's the single most important IAM concept in practice — far more consequential day to day than any specific policy syntax detail.

Compare two policies granting an application access to its own upload bucket:

JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*"
    }
  ]
}
JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::my-app-uploads/*"
    }
  ]
}

Both "work" for an application that just needs to read and write files in one bucket. The first grants every S3 action (including s3:DeleteBucket) against every bucket in the entire account; if the credentials using this policy ever leak — a compromised server, a key accidentally committed to a public repository — the blast radius is the organization's entire S3 footprint. The second grants only the two actions the application actually performs, only against the one bucket it actually owns; the same leaked credentials are then only useful for reading and writing files in that one bucket. The functional behavior of the application is identical under both policies — the only difference is how much damage a mistake or a compromise can do.

Broad policy (s3:* on *) Least-privilege policy
Grants what's needed Yes Yes
Grants extra, unused permissions Yes (everything else too) No
Blast radius if credentials leak The account's entire S3 footprint One bucket, two actions
Easier to write initially Yes Requires knowing exactly what the app does
Right choice for production No Yes

In practice, least privilege is applied iteratively: start from a reasonably scoped guess, then use IAM Access Analyzer or CloudTrail's record of actually used actions to trim a policy down further once the real access pattern is observed.

Roles for AWS services: assuming a role instead of embedding a key

The alternative to putting AWS access keys inside an application's configuration (a genuine security liability — keys in a config file get committed to git, baked into images, or leaked in logs) is a role the service assumes automatically. An EC2 instance, for example, can be launched with an instance profile attached, and the operating system on that instance can request temporary credentials for the attached role from the instance's local metadata service — no key ever stored anywhere.

A role has two parts: a trust policy (who/what is allowed to assume it) and one or more permission policies (what it can do once assumed). The trust policy for a role meant to be assumed by EC2 looks like this:

JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Creating the role and attaching it via the CLI:

Bash
aws iam create-role \
  --role-name my-app-ec2-role \
  --assume-role-policy-document file://trust-policy.json

aws iam attach-role-policy \
  --role-name my-app-ec2-role \
  --policy-arn arn:aws:iam::123456789012:policy/my-app-s3-access

aws iam create-instance-profile --instance-profile-name my-app-ec2-role
aws iam add-role-to-instance-profile \
  --instance-profile-name my-app-ec2-role \
  --role-name my-app-ec2-role

Once the instance profile is attached to a running (or newly launched) EC2 instance, the AWS SDK running on that instance picks up temporary, automatically-rotated credentials for the role with zero configuration — the same pattern Lambda functions use via an execution role, covered in the next page.

Common mistakes

  • Using your AWS account's root user for everyday work instead of creating an IAM user or role — the root user should be locked away (with MFA) and used only for the handful of account-level tasks that genuinely require it.
  • Attaching AWS-managed policies like AdministratorAccess to an application's role "to get it working," and never revisiting it — this is the single most common way least privilege quietly erodes into "everything is allowed."
  • Storing long-lived access keys in application code, environment files, or CI configuration instead of using a role the service assumes — a leaked key has no expiration on its own, while temporary role credentials rotate automatically and expire quickly.
  • Writing Resource: "*" out of convenience instead of the specific ARN(s) actually needed — it works during development and then becomes a much bigger liability once the same policy runs in production.