Skip to content
Luigi Carpio avatar
Luigi Carpio
GRC Engineer
← All writing

IAM Hardening on a Brownfield AWS Account: Assess, Remediate, Prove

aws-lab iam identity-governance nist-800-53 cjis-v6.0

Most IAM hardening walkthroughs start with an empty account and a blank slate. Real compliance work almost never does. You inherit an account someone has already been using: keys minted, policies attached, guardrails missing, and your first job is to find out what’s actually there before you change anything.

So I ran Lab 1 of my AWS Fundamentals series the way I’d run an audit engagement, not a tutorial: assess the current state, remediate the findings, then build the baseline, proving each step with the same evidence an assessor would ask for. The target was a brownfield account I’d been using for cloud-certification labs: cluttered, already the management account of an AWS Organization, with CloudTrail, Config, and Security Hub switched on by a prior course. That’s closer to a real engagement than any greenfield demo, and the mess is the point.

Step 1 — Assess: the credential report is the population

Every IAM audit starts with one question: who can do what, and how do you prove it? The instrument that answers it is the IAM credential report: a single CSV row per IAM user (plus the root user), covering MFA status, access-key age, and last use. Before touching a thing, I ran a pre-flight inventory: the credential report, the account summary, list-users / list-groups / list-roles, the account password policy, the Access Analyzer list, the Identity Center instances, and the Organizations description.

An assessment records what passes as well as what fails, so here’s the honest current state:

AreaWhat the inventory showedFindingControl
Root accountMFA enabled, no access keys, account alias setSatisfied — recorded as passingIA-2(1)
Admin access keyOne active key, ~203 days oldStale credential, rotation overdueIA-5
Password policy14-char minimum, complexity, 90-day max age — but reuse prevention absentMissing password-history controlIA-5(1)
Cost guardrailNo budget or billing alert configuredNo spend guardrail / no early warning for credential abuseOperational hygiene

AWS IAM console showing the root user's three enrolled MFA devices — a virtual authenticator app and two FIDO2 YubiKey security keys — with the account ID blacked out in each device ARN. An assessment records the passes, not just the gaps: root MFA enrolled with a TOTP app and two FIDO2 hardware keys, no root access keys. Account ID blacked out in the device ARNs.

Three gaps. None of them dramatic, which is exactly why they survive in real accounts. A 203-day-old key isn’t an incident; it’s the kind of quiet drift a periodic access review exists to catch. The “no budget” finding isn’t an access-control gap at all, but a runaway bill is often the first visible symptom of a leaked key, so I treat it as cheap early-warning telemetry rather than pure cost management.

Step 2 — Remediate before you build

The discipline that separates assessment from tinkering: you don’t stack a new baseline on top of unremediated findings. Fix what the report flagged first, then build.

  • Rotated the 203-day key. Created a fresh access key, repointed the CLI, and deleted the old one. The principal is back to a single key, created today: the IA-5 rotation gap closed and verifiable in the next credential report.
  • Added password-reuse prevention. Set PasswordReusePrevention to 5 as a surgical patch: the existing 14-character minimum, complexity, and 90-day max-age fields were all preserved, not rewritten. (More on why “90-day rotation” deserves an asterisk below.)
  • Created a cost guardrail. A $10/month budget with alerts at 80% of actual spend and 100% of forecasted spend. Small, but it turns a leaked key from a silent problem into a notification.

AWS IAM account password policy page showing a 14-character minimum, complexity requirements, 90-day expiry, and "Prevent password reuse from the past 5 changes." After the surgical patch: reuse prevention now set to the last 5 passwords, with the existing 14-character minimum, complexity, and 90-day max-age fields preserved, not rewritten.

Step 3 — Build the baseline

With the findings cleared, the baseline build is five moves: enforce MFA, make access reviewable, kill long-lived keys, watch for external exposure, and federate workforce identity.

MFA enforcement — and the one-word bug that fails open

The goal: deny everything to anyone who hasn’t authenticated with MFA, except the handful of self-service actions a user needs to enroll MFA in the first place. The naive version of this policy is a quiet disaster, and the reason is a single condition operator.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyAllExceptUnlessMFAPresent",
    "Effect": "Deny",
    "NotAction": [
      "iam:CreateVirtualMFADevice",
      "iam:EnableMFADevice",
      "iam:GetAccountPasswordPolicy",
      "iam:GetMFADevice",
      "iam:GetUser",
      "iam:ListMFADevices",
      "iam:ListUsers",
      "iam:ListVirtualMFADevices",
      "iam:ResyncMFADevice",
      "iam:ChangePassword",
      "sts:GetSessionToken"
    ],
    "Resource": "*",
    "Condition": {
      "BoolIfExists": {
        "aws:MultiFactorAuthPresent": "false"
      }
    }
  }]
}

AWS IAM policy editor in JSON view showing the RequireMFA policy — a Deny statement over a NotAction list, with the aws:MultiFactorAuthPresent condition using the BoolIfExists operator. The same policy in the console JSON editor. The whole control turns on the BoolIfExists operator in the condition block.

The condition key aws:MultiFactorAuthPresent is only included in the request context when AWS knows whether MFA was used: console sign-in, or an STS session minted with MFA. It comes through as true for an MFA session and false for one without.

Here’s the trap: a request signed with long-term IAM access keys doesn’t carry the key at all. It’s absent, not false. And a plain Bool condition on a value that’s absent simply doesn’t match — so the Deny never fires, and the request falls through to whatever Allow exists. The policy fails open for exactly the credential type you most want to gate: permanent AKIA… keys, the ones that leak in git history and CI logs.

BoolIfExists is the fix. The …IfExists form evaluates the condition as satisfied when the key is missing — so an absent MFA context is treated as “no MFA present,” the Deny fires, and long-term keys without an MFA session are blocked. Plain Bool is fail-open; BoolIfExists is fail-closed. One word.

Two details in that NotAction list earn their place:

  • sts:GetSessionToken is how a user holding nothing but long-term keys mints an MFA session whose credentials carry MFA context forward (aws sts get-session-token --serial-number <mfa-arn> --token-code <code>). AssumeRole accepts MFA too, but consumes it only at assume-time and doesn’t propagate aws:MultiFactorAuthPresent into the resulting session, so a MultiFactorAuthPresent deny on an identity-based policy is satisfied by a GetSessionToken session, not an assumed-role one. Deny it and a CLI user can never escalate to MFA, locked out of everything, including the verification step that proves the policy works.
  • The *MFADevice* / iam:Get* / iam:List* entries are based on AWS’s documented self-service set: they let a not-yet-enrolled user reach their own user page and register a device. Two are deliberate additions: iam:ChangePassword and iam:GetAccountPasswordPolicy let an expired-password CLI user self-recover, which AWS explicitly recommends against because it permits a password change without MFA. At lab scope I kept them and named the tradeoff; the stricter pattern drops both.

I didn’t take the policy’s word for it. I proved the deny. From a terminal authenticated with the admin’s long-term access key (no MFA session), aws iam list-roles returned AccessDenied: the explicit deny from policy/RequireMFA firing as designed. The same call from CloudShell, which inherits the MFA-authenticated console session, succeeded. I ran that test in a second browser session before attaching RequireMFA to any group and before signing out, so a misconfiguration couldn’t lock me out of my own account.

CloudShell showing aws iam list-roles run with the admin's long-term access key returning AccessDenied, with the account ID blacked out in the user, role, and policy ARNs of the error message. The deny, proven: the admin’s long-term access key calling list-roles is refused through policy/RequireMFA; the same call from an MFA-authenticated session succeeds. Account ID blacked out in the error ARNs.

Group-based least privilege (AC-2 / AC-6)

Access granted through group membership is access you can review and revoke as a unit, which is exactly what a user access review certifies against. A policy attached directly to a user is invisible to that review until someone goes looking for it.

So I created three groups (lab-admins, lab-auditors, lab-developers), attached RequireMFA to each, moved the admin into lab-admins, and detached the direct AdministratorAccess attachment from the user. Identical effective permissions; a governable shape. That’s the AC-2 / AC-6 distinction that shows up in an access review, not on a permissions diff.

AWS IAM User groups list showing three groups — lab-admins, lab-auditors, lab-developers — each with defined permissions. The three baseline groups. The admin now draws AdministratorAccess through lab-admins membership rather than a direct user attachment — access in a shape an access review can certify.

Roles, not long-lived keys (AC-6 / AC-3 / IA-5)

Three baseline roles, all minting short-lived credentials instead of static keys:

  • LabCrossAccountAuditor — read-only auditor access with an aws:MultiFactorAuthPresent MFA-on-assume condition in the trust policy. At lab scope the trust principal is the account itself; the pattern is structured so that principal swaps to a dedicated auditor account in production without touching the permission side.
  • LabEC2InstanceProfile — EC2 service principal, for instances that need AWS access without embedded keys.
  • LabLambdaExecutionRole — Lambda service principal.

A role you can assume is a credential that expires on its own. There’s nothing to rotate and nothing to leak.

AWS IAM Trust relationships tab for LabCrossAccountAuditor showing a trust policy that grants sts:AssumeRole under an aws:MultiFactorAuthPresent condition, with the account ID blacked out in the Summary ARN and the trust-policy principal. LabCrossAccountAuditor’s trust relationship — sts:AssumeRole gated by an aws:MultiFactorAuthPresent condition. Account ID blacked out in the Summary ARN and the :root principal.

IAM Access Analyzer (AC-3 / CA-7)

An account-level external-access analyzer, watching for resource policies that grant access outside the account’s zone of trust: the continuous-detection layer under the static policy work above.

AWS IAM Access Analyzer resource-analysis page showing the lab-account-analyzer with a "Current account" zone of trust and zero active findings. lab-account-analyzer, account zone of trust, zero external-access findings: the continuous-detection layer under the static policy work.

IAM Identity Center — and a topology change disguised as a toggle

Identity Center is the strategic answer to “stop minting per-account IAM users.” I enabled it on the existing organization and provisioned a single read-only permission set (LabReadOnly, AWS-managed ViewOnlyAccess) to the account through a LabWorkforce directory group.

AWS IAM Identity Center page for the LabReadOnly permission set, AWS accounts tab, showing it provisioned to the AWS GRC Engineering account with the account ID and email blacked out in the account row. The LabReadOnly permission set (ViewOnlyAccess) provisioned to the account through the LabWorkforce group. Account ID and email blacked out in the account row.

Step 4 — Prove it: re-run the same instrument

The loop closes by re-running the exact tool I opened with. Same credential report, before versus after:

aws iam generate-credential-report
aws iam get-credential-report --output text --query Content | base64 --decode

CloudShell output of generate-credential-report and get-credential-report showing the root and admin rows with MFA active and the account ID blacked out in each ARN. The same instrument that opened the assessment, re-run as proof: every IAM user MFA-active, the admin down to a single key created today. Account ID blacked out in the ARNs.

After the build, the report shows: root MFA on with no root access keys, the admin principal carrying MFA and a single key created today (the 203-day key gone), and every IAM user sitting behind group-attached RequireMFA. A quick policy check confirms the password fields stuck:

aws iam get-account-password-policy

CloudShell output of get-account-password-policy showing MinimumPasswordLength 14, all complexity flags true, MaxPasswordAge 90, and PasswordReusePrevention 5. PasswordReusePrevention: 5 confirmed from the CLI: the remediation stuck, and a password policy has no ARN, so nothing to redact here.

The same instrument, run before and after, is evidence, not a claim. It’s also the artifact that feeds the next stage of the portfolio: a credential report and a self-verifying attestation are precisely what an OSCAL Assessment Results pipeline wants to ingest.

The IA-5(1) asterisk most walkthroughs skip

The password policy reads “90-day rotation, complexity, history of 5.” That’s a Rev 4-shaped control, and it’s worth being honest about why.

NIST 800-53 Rev 5 IA-5(1) dropped forced periodic rotation entirely, aligning with SP 800-63B: screen new passwords against breach corpora and change credentials on evidence of compromise rather than on a calendar. That same revision also removed the password-history clause Rev 4 carried, so reuse prevention is a Rev 4-shaped knob too. And it favors length over mandatory complexity, though complexity isn’t gone from the control text: IA-5(1)(h) still lets an organization enforce its own composition rules, and many FedRAMP and CJIS shops do. CJIS v6.0 adopts the same posture, with one difference worth naming: it relaxes the legacy 90-day cycle to a 365-day maximum rather than eliminating periodic change outright.

So why configure 90-day rotation at all? Because it’s what the AWS IAM account password policy can express. The knob exists; modern guidance has no matching knob in this surface. I configured the available control and named the delta, rather than presenting calendar rotation as unqualified best practice. That gap between “what the platform lets you set” and “what the current control actually asks for” is the kind of thing an assessor probes, and it’s the difference between running a hardening script and understanding the control.

Control mappings

Each configuration tied back to the frameworks it satisfies: federal core (NIST → FedRAMP) with the CJIS v6.0 column that matters for criminal-justice information:

ConfigurationNIST 800-53 Rev 5FedRAMP HighCJIS v6.0
Root MFA enabled, no root access keysIA-2(1)IA-2(1)IA-2 — AAL2
Password policy (90-day max, 14-char min, complexity, history of 5)IA-5, IA-5(1)IA-5, IA-5(1)IA-5, IA-5(1)
MFA enforcement via policy condition (BoolIfExists)IA-2(1), IA-2(2)IA-2(1), IA-2(2)IA-2 — AAL2
Group-based least-privilege designAC-2, AC-6AC-2, AC-6AC-2, AC-6
IAM Access Analyzer enabledAC-3, CA-7AC-3, CA-7AC-3, CA-7
Cross-account auditor role (no long-lived keys)AC-6, AC-3, IA-5AC-6, AC-3, IA-5AC-6, AC-3
Identity Center federated workforce accessIA-2(8), AC-2, AC-3IA-2(8), AC-2, AC-3IA-2(8), AC-2, AC-3

Two notes on the CJIS column. First, “AAL2” there is the NIST SP 800-63B authenticator-assurance level CJIS v6.0 references for advanced authentication, not a CJIS control number. Second, on the hardware: the admin’s MFA is a TOTP app plus two FIDO2 hardware keys. FIDO2 is phishing-resistant, and a FIPS 140-validated hardware key meets AAL3, so this configuration exceeds the CJIS v6.0 IA-2 AAL2 floor for criminal-justice information. One CLI nuance is worth naming: a FIDO2/WebAuthn assertion can’t be passed to sts:get-session-token, which only accepts a six-digit TOTP code. So a FIDO2-only user reaches the CLI either through aws login (AWS CLI v2.32.0+, browser-based WebAuthn that mints auto-refreshing temporary credentials) or by enrolling a TOTP authenticator as a second device. The hardware keys cover console sign-in; the TOTP app is what backs get-session-token.

What’s next

This was the console walkthrough: the human-legible version an assessor can follow click by click. The next post codifies the same baseline as a reusable Terraform module (iam-hardening) whose compliance_attestation output computes its booleans from deployed resource state, not from input variables, so the evidence is self-verifying rather than self-reported. That module becomes Component Definition input for the OSCAL evidence pipeline, and the series continues down the AWS Fundamentals path: CloudTrail logging, S3 encryption, KMS key management, Config rules, and Security Hub. Each one assessed, remediated, and proven the same way.

Assess, remediate, prove. The account was never the deliverable. The evidence loop is.