Back to blog
AWSBest PracticesCloud SecurityIdentity & AccessOperations & Compliance

IAM User Has Directly Attached Policies: Why Groups Matter

Learn why directly attaching policies to IAM users hurts access management, and how to move permissions to groups with CLI, Terraform, and policy-as-code.

TL;DR

This check flags IAM users with policies attached directly to them instead of inherited through groups. Direct attachments scatter permissions across your account and make audits painful. Fix it by moving the user into an IAM group that carries the policy, then detaching the inline or managed policy from the user.

Attaching a policy straight onto an IAM user feels like the fast path. You need to grant someone S3 access, you run one command, done. The problem shows up six months later when you have forty users, each with a slightly different bundle of directly attached policies, and nobody can answer the question "who can write to this bucket?" without clicking through every user in the console.

The user_directpolicies check catches this pattern early. It looks at every IAM user in your AWS account and reports any that have policies bound directly to the user identity rather than received through group membership.


What this check detects

AWS lets you grant permissions to an IAM user in several ways:

  • Group-attached policies — the user belongs to a group, and the group carries managed policies. This is the recommended model.
  • Directly attached managed policies — an AWS managed or customer managed policy bound straight to the user.
  • Inline policies — a policy embedded inside the user object itself with no separate ARN.

The check flags users that fall into the second or third category. If a user has any managed or inline policy attached to the user identity directly, it gets reported.

Note: IAM groups are not security principals. You cannot assume a group or set it as a trust target. They exist purely as containers that distribute permissions to the users inside them. That is exactly why they are the right tool for managing human access at scale.


Why it matters

Direct policy attachment is not a vulnerability on its own. A user with one directly attached AmazonS3ReadOnlyAccess policy is not going to get you breached. The risk is operational, and it compounds over time.

Audits become guesswork

When permissions live on groups, you answer access questions by looking at group membership. A small number of groups, each with a clear purpose, maps cleanly to job functions. When permissions live on individual users, every access review turns into a per-user inspection. That is slow, error prone, and the kind of work that quietly gets skipped.

Permission drift and over-provisioning

Direct attachments tend to accumulate. Someone needs temporary access for a migration, a policy gets attached, the migration finishes, and the policy stays. Multiply that across a team and you end up with users holding permissions nobody remembers granting. This is the raw material for privilege escalation if any one of those credentials leaks.

Inconsistent access for the same role

Two engineers doing the same job should have the same permissions. With group-based access that happens automatically. With direct attachment you rely on whoever set up each user remembering to apply the exact same set of policies. They never do.

Warning: Long-lived IAM users with directly attached policies are a favorite target for attackers who find leaked access keys in commits, CI logs, or public buckets. The wider the permission set on that user, the more an attacker gets from a single leaked key.


How to fix it

The goal is to move the permissions onto a group and remove the direct attachment from the user. Here is the full sequence.

1. See what is attached to the user

aws iam list-attached-user-policies --user-name alice
aws iam list-user-policies --user-name alice

The first command lists managed policies attached directly. The second lists inline policies. You need both, because they are removed with different commands.

2. Create or pick a group that matches the user's role

aws iam create-group --group-name developers

3. Attach the policy to the group

aws iam attach-group-policy \
  --group-name developers \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

4. Add the user to the group

aws iam add-user-to-group \
  --group-name developers \
  --user-name alice

5. Confirm the user still has effective access, then detach the direct policy

Before removing anything, verify the new group grants what you expect. The IAM policy simulator is the safe way to do this:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:user/alice \
  --action-names s3:GetObject \
  --resource-arns arn:aws:s3:::my-bucket/*

Danger: Detaching policies removes access immediately. If the user relies on those permissions for production work or automated jobs, confirm the group grants equivalent access first. Removing the wrong policy can break a running pipeline or lock someone out of a resource they need.

Once you have confirmed access flows through the group, remove the direct attachment.

For a managed policy:

aws iam detach-user-policy \
  --user-name alice \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

For an inline policy:

aws iam delete-user-policy \
  --user-name alice \
  --policy-name s3-temp-access

Tip: If you have inline policies you want to keep, convert them to customer managed policies first with aws iam create-policy, then attach the new policy to a group. That makes the policy reusable and version controlled instead of trapped inside a single user.


Doing this with infrastructure as code

If you manage IAM in Terraform, the fix is structural. Define groups, attach policies to groups, and add users with a membership resource. Never put a aws_iam_user_policy_attachment on a human user.

resource "aws_iam_group" "developers" {
  name = "developers"
}

resource "aws_iam_group_policy_attachment" "dev_s3" {
  group      = aws_iam_group.developers.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}

resource "aws_iam_user" "alice" {
  name = "alice"
}

resource "aws_iam_user_group_membership" "alice_groups" {
  user   = aws_iam_user.alice.name
  groups = [aws_iam_group.developers.name]
}

With this layout, granting a new engineer the same access is a one-line change to the membership list. Permissions stay defined in one place per role.


How to prevent it from happening again

Manual cleanup fixes today's problem. Preventing recurrence means catching direct attachments before they land.

Block the pattern in code review

If you use Terraform, a policy-as-code gate catches the bad pattern in CI. This OPA/Conftest rule rejects any plan that attaches a policy directly to a user:

package iam

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_iam_user_policy_attachment"
  msg := sprintf("Policy attached directly to user %v. Use a group instead.", [resource.address])
}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_iam_user_policy"
  msg := sprintf("Inline policy on user %v. Move it to a group.", [resource.address])
}

Wire this into your pipeline so a plan that introduces a direct attachment fails before merge.

Use a service control policy

If you run AWS Organizations, an SCP can deny the relevant API calls account-wide. This stops direct attachment even for users acting through the console.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDirectUserPolicyAttachment",
      "Effect": "Deny",
      "Action": [
        "iam:AttachUserPolicy",
        "iam:PutUserPolicy"
      ],
      "Resource": "*"
    }
  ]
}

Warning: An SCP that blocks iam:AttachUserPolicy applies to everyone in the targeted accounts, including your automation and break-glass roles. Test it in a non-production OU first, and make sure your IAM provisioning tooling uses group attachments before you roll it out.

Run the check continuously

Configuration drifts. Someone with console access can always click around the gate. Running user_directpolicies on a schedule with Lensix means a new direct attachment surfaces in your findings within the next scan rather than at your next annual audit.


Best practices

  • Map groups to job functions. Keep group names readable, like developers, billing-readonly, or db-admins, so membership maps cleanly to what people actually do.
  • Prefer roles over long-lived users entirely. The cleanest version of this fix is to not have IAM users at all. Use IAM Identity Center or federated roles for humans, so access is temporary and centrally managed. Direct attachment becomes a non-issue when there are no standing user credentials.
  • Keep groups small in number. A handful of well-scoped groups beats dozens of overlapping ones. If you are creating a group per person, you have recreated the problem with extra steps.
  • Review group membership, not user permissions. Once permissions live on groups, your quarterly access review becomes a check of who is in which group. Far faster than per-user inspection.
  • Avoid inline policies for human users. Inline policies are invisible in most permission inventories and cannot be reused. Reserve them for the rare cases where a policy must be tightly coupled to a single resource, like a role's inline trust scoping.

Direct policy attachment is one of those issues that costs nothing to ignore on day one and a great deal to untangle on day five hundred. Moving permissions to groups now keeps your IAM surface legible, and legible IAM is the foundation everything else in cloud security stands on.