I tried automatically disabling unused IAM users while excluding specific users using AWS Config + Lambda
Introduction
Hello everyone, I am Asano from the Consulting Department of the Cloud Business Division.
Using AWS Config's managed rule "iam-user-unused-credentials-check," you can detect IAM users who haven't used their passwords or access keys for a certain period as "non-compliant," and automatically disable them using an SSM Document.
However, in actual operations, there may be users that you want to exclude from being disabled, such as important administrator accounts or operational accounts that are used infrequently.
So this time, I've deployed a system using CloudFormation to build an AWS Config custom Lambda rule that includes a mechanism to exclude specific IAM users, automatically disabling unused IAM users all at once.
For information on how to disable IAM users that have been unused for a certain period using the managed rule "iam-user-unused-credentials-check," the following blog is helpful!
https://dev.classmethod.jp/articles/automation-revoke-unused-iam-user-credentials/## CloudFormation
Source code
GitHub - haruki-0408/aws-iam-unused-user-cleanup
This includes the following resource types:
- AWS::IAM::Role: Execution role for custom Lambda Config rule
- AWS::Lambda::Function: Custom Lambda function to detect unused IAM users
- AWS::Lambda::Permission: Grants Config permissions to Lambda
- AWS::Config::ConfigRule: Custom Config rule (runs at 24-hour intervals)
- AWS::IAM::Role: Service role for Config auto-remediation
- AWS::Config::RemediationConfiguration: Auto-remediation settings (disabling via SSM Document)
AWSTemplateFormatVersion: '2010-09-09'
Description: Detect unused IAM users with custom Lambda Config rule and auto-remediate excluding specific IAM users
Parameters:
ExcludedUsersParameterName:
Type: String
Default: "/iam-unused-user-cleanup/excluded-users"
Description: SSM Parameter Store path for excluded IAM users list
MaxCredentialUsageAge:
Type: String
Default: "30"
Description: Maximum number of days a credential can be unused before being revoked
Resources:
# Execution role for custom Lambda Config rule
ConfigRuleLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: ConfigRuleLambdaRole
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole
Policies:
- PolicyName: IAMReadAndParameterAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- iam:ListUsers
- iam:GetUser
- iam:GetLoginProfile
- iam:ListAccessKeys
- iam:GetAccessKeyLastUsed
- ssm:GetParameter
Resource: "*"
# Custom Lambda Config rule
ConfigRuleLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: custom-unused-iam-user-credentials-check-function
Runtime: python3.13
Timeout: 300
MemorySize: 256
Handler: index.lambda_handler
Role: !GetAtt ConfigRuleLambdaRole.Arn
Environment:
Variables:
EXCLUDED_USERS_PARAMETER: !Ref ExcludedUsersParameterName
MAX_CREDENTIAL_AGE: !Ref MaxCredentialUsageAge
Code:
ZipFile: |
import boto3
import os
from datetime import datetime, timedelta, timezone
iam = boto3.client('iam')
ssm = boto3.client('ssm')
config_client = boto3.client('config')
def lambda_handler(event, context):
evaluations = []
excluded_users = get_excluded_users()
max_days = int(os.environ.get('MAX_CREDENTIAL_AGE', '90'))
# Get all IAM users and evaluate them
paginator = iam.get_paginator('list_users')
for page in paginator.paginate():
for user in page['Users']:
user_name = user['UserName']
print(f"Evaluating: {user_name}")
if user_name in excluded_users:
compliance = 'COMPLIANT'
print(f"Excluded user: {user_name}")
elif is_credentials_unused(user_name, max_days):
compliance = 'NON_COMPLIANT'
print(f"Unused user: {user_name}")
else:
compliance = 'COMPLIANT'
print(f"Active user: {user_name}")
evaluations.append({
'ComplianceResourceType': 'AWS::IAM::User',
'ComplianceResourceId': user['UserId'],
'ComplianceType': compliance,
'OrderingTimestamp': datetime.now(timezone.utc)
})
# Send evaluation results to Config
if evaluations:
config_client.put_evaluations(
Evaluations=evaluations,
ResultToken=event['resultToken']
)
print(f"Evaluation complete: {len(evaluations)} users")
return {'evaluations_count': len(evaluations)}
def get_excluded_users():
parameter_name = os.environ.get('EXCLUDED_USERS_PARAMETER', '')
if not parameter_name:
return []
try:
response = ssm.get_parameter(Name=parameter_name, WithDecryption=False)
return [u.strip() for u in response['Parameter']['Value'].split(',') if u.strip()]
except ssm.exceptions.ParameterNotFound:
print(f"Excluded list parameter not found: {parameter_name}")
return []
def is_credentials_unused(user_name, max_days):
cutoff_date = datetime.now(timezone.utc) - timedelta(days=max_days)
print(f"Cutoff date: {cutoff_date}")
# Check password usage (aligned with remediation logic)
user_info = iam.get_user(UserName=user_name)
password_valid = True # Default is valid
try:
login_profile = iam.get_login_profile(UserName=user_name)
password_needs_remediation = False
if 'PasswordLastUsed' in user_info['User']:
last_used = user_info['User']['PasswordLastUsed']
last_used_days = (datetime.now(timezone.utc) - last_used).days
print(f"Password last used: {last_used} ({last_used_days} days ago)")
if last_used_days >= max_days:
password_needs_remediation = True
else:
# If password never used, check creation date
create_date = login_profile['LoginProfile']['CreateDate'].replace(tzinfo=timezone.utc)
create_days = (datetime.now(timezone.utc) - create_date).days
print(f"Password creation date: {create_date} ({create_days} days ago)")
if create_days >= max_days:
password_needs_remediation = True
if password_needs_remediation:
print(f"Expired password detected: {user_name}")
password_valid = False
except iam.exceptions.NoSuchEntityException:
print(f"No login profile: {user_name}")
# If no password, consider it valid (not for remediation)
# Check access key usage (individually evaluated to align with remediation logic)
access_keys = iam.list_access_keys(UserName=user_name)
access_key_valid = True # Default is valid
if not access_keys['AccessKeyMetadata']:
print(f"No access keys: {user_name}")
else:
for key in access_keys['AccessKeyMetadata']:
if key['Status'] == 'Active': # Check only Active keys
key_usage = iam.get_access_key_last_used(AccessKeyId=key['AccessKeyId'])
key_needs_remediation = False
if 'LastUsedDate' in key_usage['AccessKeyLastUsed']:
last_used = key_usage['AccessKeyLastUsed']['LastUsedDate']
last_used_days = (datetime.now(timezone.utc) - last_used).days
print(f"Access key last used: {last_used} ({last_used_days} days ago)")
if last_used_days >= max_days:
key_needs_remediation = True
else:
# If access key never used, check creation date
create_date = key['CreateDate']
create_days = (datetime.now(timezone.utc) - create_date).days
print(f"Access key creation date: {create_date} ({create_days} days ago)")
if create_days >= max_days:
key_needs_remediation = True
if key_needs_remediation:
print(f"Expired access key detected: {key['AccessKeyId']}")
access_key_valid = False # If any key is expired, mark as invalid
# OR condition: If either password or access key is invalid (needs remediation), mark as NON_COMPLIANT
result = not password_valid or not access_key_valid
print(f"Password valid: {password_valid}, Access key valid: {access_key_valid}, Unused determination: {result}")
return result
# Config permission for Lambda
ConfigRuleLambdaPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref ConfigRuleLambda
Action: lambda:InvokeFunction
Principal: config.amazonaws.com
SourceAccount: !Ref AWS::AccountId
# Custom Config rule
ConfigRule:
Type: AWS::Config::ConfigRule
DependsOn: ConfigRuleLambdaPermission
Properties:
ConfigRuleName: custom-unused-iam-user-credentials-check
Description: "Detect IAM users with unused credentials (excluding specified users)"
Source:
Owner: CUSTOM_LAMBDA
SourceIdentifier: !GetAtt ConfigRuleLambda.Arn
SourceDetails:
- EventSource: aws.config
MessageType: ScheduledNotification
MaximumExecutionFrequency: TwentyFour_Hours
Scope:
ComplianceResourceTypes:
- "AWS::IAM::User"
# Service role for Config Remediation
RemediationServiceRole:
Type: AWS::IAM::Role
Properties:
RoleName: ConfigRemediationServiceRole
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ssm.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: IAMUserManagementPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- config:ListDiscoveredResources
- ssm:GetAutomationExecution
- iam:DeleteAccessKey
- ssm:StartAutomationExecution
- iam:GetAccessKeyLastUsed
- iam:UpdateAccessKey
- iam:GetUser
- iam:GetLoginProfile
- iam:DeleteLoginProfile
- iam:ListAccessKeys
Resource: "*"
# Config Remediation settings
RemediationConfiguration:
Type: AWS::Config::RemediationConfiguration
Properties:
ConfigRuleName: !Ref ConfigRule
TargetType: SSM_DOCUMENT
TargetId: AWSConfigRemediation-RevokeUnusedIAMUserCredentials
TargetVersion: "5"
Parameters:
AutomationAssumeRole:
StaticValue:
Values:
- !GetAtt RemediationServiceRole.Arn
IAMResourceId:
ResourceValue:
Value: RESOURCE_ID
MaxCredentialUsageAge:
StaticValue:
Values:
- !Ref MaxCredentialUsageAge
Automatic: true
MaximumAutomaticAttempts: 5
RetryAttemptSeconds: 60
```The following items are managed outside of CloudFormation.
- Excluded user list: Managed in SSM Parameter Store
- Key: `/iam-unused-user-cleanup/excluded-users`
- Value example: `admin-user,emergency-user,service-account`
- Format: Comma-separated string (StringList) specifying multiple IAM user names
Also, the following settings can be freely configured:
- Config rule evaluation frequency: Choose from every 3 hours, 6 hours, 12 hours, 24 hours, or once a week (configured in CloudFormation template)
- Auto-remediation retry count for non-compliant resources: Default 5 attempts (maximum retry count when remediation fails)
- Retry interval: Default 60 seconds (execution interval between remediation retries)
- Number of days to define an IAM user as "unused": Default 90 days (can be changed with CloudFormation parameter `MaxCredentialUsageAge`)## Process Flow
The overall flow of the system we created follows the guidelines below.
Care must be taken to align the unused IAM user determination with the determination logic in the later SSM remediation document "AWSConfigRemediation-RevokeUnusedIAMUserCredentials" to avoid false detections or incomplete remediation.
The password and access key valid/invalid detection sections are aligned with the logic in the script of version `5` of the SSM remediation document. When changing the version of the remediation document in CloudFormation, care must be taken to ensure that the custom Lambda rule logic doesn't become misaligned.
```mermaid
flowchart TD
Config[Config] -->|Configurable frequency| Rule[Config Rule]
Rule -->|Execute| Lambda[Lambda function]
Lambda -->|Get| SSM[SSM Parameter<br/>exclusion list]
Lambda -->|Get| IAM[IAM Users]
Lambda --> Check{Exclusion list<br/>check}
Check -->|Included| OK1[COMPLIANT]
Check -->|Not included| Cred[Credential evaluation]
subgraph PassFlow ["Password evaluation flow"]
Pass[Get user information] --> PassHasUsed{Last used date<br/>exists?}
PassHasUsed -->|YES| PassUsedCheck[Unused days exceed<br/>configured period?]
PassHasUsed -->|NO| PassLoginProfile{Login profile<br/>exists?}
PassUsedCheck -->|NO| PassValid[Valid]
PassUsedCheck -->|YES| PassInvalid[Invalid]
PassLoginProfile -->|YES| PassCreateCheck[Creation days exceed<br/>configured period?]
PassLoginProfile -->|NO| PassValid2[Valid]
PassCreateCheck -->|NO| PassValid3[Valid]
PassCreateCheck -->|YES| PassInvalid2[Invalid]
end
subgraph KeyFlow ["Access key evaluation flow"]
Key[Get access key list] --> KeyExists{Access keys<br/>exist?}
KeyExists -->|NO| KeyValid[Valid]
KeyExists -->|YES| KeyLoop[Check each active<br/>key individually]
KeyLoop --> KeyHasUsed{Last used date<br/>exists?}
KeyHasUsed -->|YES| KeyUsedCheck[Unused days exceed<br/>configured period?]
KeyHasUsed -->|NO| KeyCreateCheck[Creation days exceed<br/>configured period?]
KeyUsedCheck -->|YES| KeyInvalid[Invalid]
KeyUsedCheck -->|NO| KeyContinue[Next key]
KeyCreateCheck -->|YES| KeyInvalid2[Invalid]
KeyCreateCheck -->|NO| KeyContinue
KeyContinue --> KeyEnd{All keys<br/>checked?}
KeyEnd -->|NO| KeyLoop
KeyEnd -->|YES| KeyValid2[Valid]
end
Cred --> PassFlow
Cred --> KeyFlow
PassFlow --> OR{OR condition<br/>either invalid?}
KeyFlow --> OR
OR -->|No| OK2[COMPLIANT]
OR -->|Yes| NG[NON_COMPLIANT]
OK1 --> End[Send Config result]
OK2 --> End
NG --> End
End --> Remediation{Remediation execution}
Remediation -->|NON_COMPLIANT only| SSMDoc[AWSConfigRemediation-<br/>RevokeUnusedIAMUserCredentials<br/>remediate only detected invalid parts]
style Lambda fill:#e3f2fd,color:#000
style Check fill:#f3e5f5,color:#000
style OR fill:#fff3e0,color:#000
style SSMDoc fill:#e8f5e8,color:#000
style PassValid fill:#c8e6c9,color:#000
style PassValid2 fill:#c8e6c9,color:#000
style PassValid3 fill:#c8e6c9,color:#000
style PassInvalid fill:#ffcdd2,color:#000
style PassInvalid2 fill:#ffcdd2,color:#000
style KeyValid fill:#c8e6c9,color:#000
style KeyValid2 fill:#c8e6c9,color:#000
style KeyInvalid fill:#ffcdd2,color:#000
style KeyInvalid2 fill:#ffcdd2,color:#000
```## Action Confirmation
For this implementation, we set the number of days defined as "unused" (MaxCredentialUsageAge) to `30` and deployed CloudFormation.
This means IAM users whose passwords or access keys have been unused for more than 30 days will be marked as "non-compliant."
Let's verify how this works in practice.
### 1. Test IAM Users
For this verification, we created the following three IAM users:

| Username | Last Activity | Time Elapsed Since Password Creation | Last Console Sign-in | Access Key ID | Time Elapsed Since Active Key Creation |
|-----------|-------------------|--------------------------------|--------------------------|-----------------|---------------------------------------|
| test-active-user | 48 days ago | 49 days | July 10, 2025, 11:21 (UTC+09:00) | - | - |
| test-active-user2 | - | 48 days | - | Active - AKIA*********** | 57 minutes |
| test-active-user3 | - | 17 hours | - | - | - |
Each user should be evaluated by the Config rule as follows:
- test-active-user: Can sign in to the console and has no access keys. Last login was 48 days ago → **"Non-compliant" target**
- test-active-user2: Can sign in to the console and created an access key 1 hour ago, password created more than 48 days ago with no login → **"Non-compliant" target**
- test-active-user3: Can sign in to the console and has no access keys. No login yet → **"Compliant" target**
### 2. Registration in the Exclusion List
Before testing the functionality, we'll create a list of IAM users in SSM Parameter Store that we want to exclude from this rule, which is a key feature.
As shown below, we registered `test-active-user` to be excluded from the Config custom rule:

Normally, `test-active-user` would be deemed "non-compliant" since the last password use (console sign-in) was more than 30 days ago, but by registering it in this exclusion list, we make it exempt from detection.### 3. Detection Content
After deploying, the following Config custom rule was created.


The `MaxCredentialUsageAge` has been successfully set to `30` as a configuration value in the remediation document.
The actual custom rule is this function found in the Lambda console screen.


The Lambda environment variables define the location of the exclusion list and the number of days to determine "unused" credentials. Make sure this value matches the value in the remediation document mentioned earlier to avoid inconsistent behavior.
In this project, these are unified as CloudFormation parameters, so you only need to change that part.
Next, let's check the resources detected by this rule.

As expected, `test-active-user` and `test-active-user3` were evaluated as "compliant."

Also as expected, `test-active-user2` has been correctly identified as "non-compliant"!
Let's check the custom Lambda rule logs. We can see the evaluation logic for each case.

We can confirm that the evaluation logic is working properly. Excellent!
Since we've configured automatic remediation for "non-compliant" resources, after a few minutes we can see "Action executed successfully" indicating that remediation for `test-active-user2` has been completed.

Let's also check the remediation execution records in more detail using AWS CLI.
```bash
# Command to check remediation execution status
aws configservice describe-remediation-execution-status --config-rule-name custom-unused-iam-user-credentials-check \
{
"RemediationExecutionStatuses": [
{
"ResourceKey": {
"resourceType": "AWS::IAM::User",
"resourceId": "******************"
},
"State": "SUCCEEDED",
"StepDetails": [
{
"Name": "RevokeUnusedIAMUserCredentialsAndVerify",
"State": "SUCCEEDED",
"StartTime": "2025-08-28T11:27:52.846000+09:00",
"StopTime": "2025-08-28T11:28:06.138000+09:00"
}
],
"InvocationTime": "2025-08-28T11:27:52.515000+09:00",
"LastUpdatedTime": "2025-08-28T11:28:06.514000+09:00"
}
]
}
```The set remediation action has been executed and changed to successful status.
I'm also looking at the execution log of the SSM document.
```bash
aws ssm describe-automation-executions \
--filters "Key=DocumentNamePrefix,Values=AWSConfigRemediation-RevokeUnusedIAMUserCredentials" \
--max-results 1
{
{
"AutomationExecutionMetadataList": [
{
"AutomationExecutionId": "******************",
"DocumentName": "AWSConfigRemediation-RevokeUnusedIAMUserCredentials",
"DocumentVersion": "5",
"AutomationExecutionStatus": "Success",
"ExecutionStartTime": "2025-08-28T11:27:52.370000+09:00",
"ExecutionEndTime": "2025-08-28T11:28:06.197000+09:00",
"ExecutedBy": "arn:aws:sts::**********:assumed-role/AWSServiceRoleForConfigRemediation/AwsConfigRemediation",
"LogFile": "",
"Outputs": {
"RevokeUnusedIAMUserCredentialsAndVerify.Output": [
"{\"output\":\"Verification of unused IAM User credentials is successful.\",\"http_responses\":{\"DeactivateUnusedKeysResponse\":[],\"DeleteUnusedPasswordResponse\":{\"ResponseMetadata\":{\"RequestId\":\"22bce8dc-9371-46f3-a366-abda08b72b9d\",\"HTTPStatusCode\":200,\"HTTPHeaders\":{\"date\":\"Thu, 28 Aug 2025 02:28:01 GMT\",\"x-amzn-requestid\":\"22bce8dc-9371-46f3-a366-abda08b72b9d\",\"content-type\":\"text/xml\",\"content-length\":\"216\"},\"RetryAttempts\":0}}}}"
]
},
"Mode": "Auto",
"Targets": [],
"ResolvedTargets": {
"ParameterValues": [],
"Truncated": false
},
"AutomationType": "Local"
}
],
"NextToken": "**********"
}
It's properly using document version 5
and we can see the DeleteUnusedPasswordResponse
entry, which confirms that the password for the unused IAM user has been deleted.
Let's check this in the console.
We successfully confirmed that the "Console access" for test-active-user2
has been switched to "Disabled" and the password has been deleted!
Furthermore, after remediation, we confirmed that when the Config rule was re-evaluated, the "non-compliant" resources were remediated and all changed to "compliant".
In Conclusion
In this article, we created a system that detects and disables users whose "passwords" or "access keys" have not been used for a certain period of time, using AWS Config rule evaluation with a custom Lambda rule.
By creating an exclusion list in SSM Parameter Store, we aimed to create a more flexible system.
I hope this system and blog post will be helpful to someone.