Back to blog
IAMZero TrustAWS

Why standing access is your biggest IAM risk (and how to fix it)

May 18, 2025 11 min read

In every AWS environment I've reviewed — and I've reviewed a lot of them — the most consistent finding isn't a misconfigured security group or an unencrypted volume. It's standing access.

Standing access means a principal (user, role, service account) holds elevated permissions permanently. Always on. Never expiring. It exists because it's the default, and revoking it takes effort nobody has time for.

It's also the reason that when a credential leaks, the blast radius is enormous.

What standing access looks like in practice

Here's a pattern I see constantly:

A developer needs to query a production RDS instance to debug a customer issue. The quickest solution: add rds:* to their IAM user. The issue gets fixed. The permission stays — forever.

Six months later, nobody remembers why that permission exists. The developer has moved to a different team. The access key hasn't been rotated. And now it's sitting in a .env file on their old laptop.

This isn't a hypothetical. It's the exact chain of events in at least three incidents I've reviewed.

The access key problem

IAM access keys are the worst offender. They're long-lived, they get copied into config files, and they're trivially credential-stuffed.

The AWS console makes it easy to create them and hard to notice when they haven't been used in months.

A quick check to find stale keys in your account:

aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | base64 -d | \
  awk -F',' 'NR>1 && $10 != "N/A" {
    cmd = "date -d " $10 " +%s 2>/dev/null || date -j -f %Y-%m-%dT%H:%M:%S " $10 " +%s"
    cmd | getline ts; close(cmd)
    age = (systime() - ts) / 86400
    if (age > 90) print $1, $10, int(age) " days"
  }'

Any key not used in 90 days should be disabled. Any key not used in 180 days should be deleted. Most accounts I look at have keys that haven't been touched in over a year.

Just-in-time access: the fix

JIT access means a principal requests elevated access for a specific task, for a bounded time window. When the window closes, the access is gone.

In AWS, the cleanest way to implement this is with IAM roles and STS AssumeRole with a short session duration:

# Request a 1-hour session with production read access
aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/ProdReadOnly \
  --role-session-name debug-session-$(date +%s) \
  --duration-seconds 3600

The credentials returned expire after one hour. No cleanup required.

A practical JIT setup using AWS SSO and permission sets

For teams, the most maintainable approach I've found is AWS IAM Identity Center (SSO) with purpose-built permission sets and an approval step layered in front.

The permission set for "production database read access" looks like this in Terraform:

resource "aws_ssoadmin_permission_set" "prod_db_read" {
  name             = "ProdDatabaseReadOnly"
  instance_arn     = local.sso_instance_arn
  session_duration = "PT2H"  # 2 hour max
}

resource "aws_ssoadmin_managed_policy_attachment" "rds_read" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.prod_db_read.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AmazonRDSReadOnlyAccess"
}

The key is session_duration. Even if someone gets hold of a session token, it expires. The window for damage is bounded.

What about service accounts?

JIT for humans is straightforward. Service accounts are harder because automation can't approve its own access requests.

The answer is OIDC. For GitHub Actions:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/GitHubDeploy
      role-session-name: deploy-${{ github.run_id }}
      aws-region: us-east-1

No access keys anywhere. The OIDC token is short-lived and scoped to the specific repository and branch. The IAM role trusts the GitHub OIDC provider, not a static key.

The Terraform to set up the OIDC trust is in the Vijenex repos — it's one of the scripts I use as a baseline on every new AWS account.

What to actually change first

If you're reading this looking for a starting point:

  1. Disable unused access keys. Run the credential report, disable anything inactive for 90+ days. Takes 20 minutes.
  2. Find iam:PassRole grants. Any principal with this permission is a lateral movement risk. Scope it to specific roles.
  3. Migrate CI/CD to OIDC. Pick one pipeline. Remove its access keys. Switch to OIDC. Repeat.
  4. Set session duration on roles. Default is 1 hour, maximum is 12. For most workloads, 1 hour is fine. For humans doing manual work, 4 hours is usually enough.

Standing access is a cultural problem as much as a technical one. The tooling to fix it exists and it's not complicated. The hard part is making it the default instead of the exception.

All posts