
An IAM role is a short-lived identity your workload assumes. A long-term access key on a user is the thing you are trying to stop issuing.
AKIA keys sit in git, in laptops, and in CI variables. ASIA keys expire. The prefix is a hint. The blast radius is the policy attached to that identity.
The usual mistake is creating a user for a Lambda ‘because it was faster,’ then never rotating the key that leaked in a log.
This page is when to use a role, when a user is the wrong door, and the checks that find leftover AKIA keys.
AKIA is the prefix AWS documents for a long-term access key on an IAM user or the root user. ASIA is the prefix for a temporary STS credential.
The control is a role the SDK already knows how to refresh, a policy that names actions and ARNs, a condition key that older IAM write-ups claimed did not exist, and a credential report that shows zero active user keys on the app path.
Pair this page with the secure coding checklist for least privilege, with Ubuntu host hardening for the instance that assumes the role, and with dependency hygiene when a key was committed instead of assumed.
The compute assumes a role and walks out with ASIA. A long-lived AKIA key in the app environment is a dead end.
SecureCoding
Roles issue ASIA, users issue AKIA
Secure access keys page. AWS says to use temporary credentials from a role instead of long-term keys. The same page, under auditing, says ids that start with AKIA are long-term credentials for an IAM user or the root user. Ids that start with ASIA come from STS. That is the whole fork.
An application on EC2, ECS, EKS, or Lambda does not need a user. It needs a role. The instance profile, the task role, the function role: those are how the SDK finds a session. You do not export AWS_ACCESS_KEY_ID into the unit file. You do not paste a key into GitHub Actions secrets when the repo can assume via OIDC. You do not mint a root key. Ever.
Humans should not be IAM users either if Identity Center is available. Identity Center issues a session. The person assumes AppReadRole or an admin role with a permission set you wrote. A six-type taxonomy that mixed ACLs with session policies is not the 2026 split. The 2026 split is simpler: identity policy on the role, resource policy on the bucket, permission boundary if you delegate role creation, SCP if you have an Organization. Session policies exist for federation. They do not justify keeping AKIA.
On-prem or a laptop that is not in AWS can still avoid a user key. IAM Roles Anywhere issues short-lived credentials from a certificate you control. GitHub Actions uses an OIDC provider and a trust policy on AppReadRole that checks token.actions.githubusercontent.com and your repo. I am not citing a vendor pitch that says keys are banned in every edge case. A leftover vendor SFTP client that can only store a user key is a ticket to replace the client. Do not mint a second key for the web app while you wait.
Name the service and the action
AdministratorAccess is not a sprint shortcut. It is a second root. Write AppReadPolicy and attach it only to AppReadRole. Name the action. Name the object prefix. List is a separate action. Put is a separate action. Do not add s3:* because the first Get failed.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadInvoicesOnly",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::app-uploads/invoices/*"
},
{
"Sid": "ListInvoicesPrefix",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::app-uploads",
"Condition": {
"StringLike": { "s3:prefix": ["invoices/", "invoices/*"] }
}
}
]
}
The trust policy is the other half. A role that any account can assume is not least privilege. Name the principal. For an instance profile, the service is ec2.amazonaws.com. For GitHub, name the OIDC provider and the repo sub claim. For a human permission set, name the Identity Center role path, not Principal *.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "Ec2Only",
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
Cross-account trust is a named account id in that Principal, plus an external id or an org condition if you have one. It is a trust statement you can read with get-role. If you cannot name the other account, do not create the trust.
GitHub Actions is the CI case people still solve with a user key in repository secrets. Prefer an OIDC provider and a trust statement that checks the audience and the sub. The sub should name your org and repo, not every repo on github.com.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "GithubRepoOnly",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-app:*"
}
}
}]
}
Replace your-org/your-app with the repository that may deploy. A wildcard org is how a fork workflow assumes AppReadRole. Security Blog page named above for OIDC as the CI replacement. I have not run this JSON against your provider ARN. Create the OIDC provider first. Then attach the trust. Then delete the leftover-ci user.
Region and type the policy can deny
A common 2021 claim was that a stolen role key can spin EC2 in regions you do not use, and that IAM cannot cap region or instance size. Condition keys exist. aws:RequestedRegion is in the global condition key list. ec2:InstanceType is an EC2 condition key. They do not cap the bill the way a Service Quota does. They do stop RunInstances in ap-southeast-2 when you only operate in us-east-1. Say that honestly. Do not claim IAM is a billing firewall.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "RunOnlySmallInHomeRegion",
"Effect": "Allow",
"Action": ["ec2:RunInstances", "ec2:DescribeInstances"],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "us-east-1",
"ec2:InstanceType": ["t3.micro", "t3.small"]
}
}
}]
}
Attach that only to a role that must launch instances. AppReadRole should not have RunInstances at all. A read role that can also run p5.48xlarge in every Region is how a leaked session becomes a bill. Quotas are a second belt. Keep the condition anyway.
SCPs you can name honestly
A service control policy is a guardrail on an account or OU. It does not grant. Organizations SCP examples page. AWS says those snippets are for information only, that you still attach identity policies to grant, and that a deny-list setup still needs FullAWSAccess or another allow attached. FullAWSAccess is an AWS managed policy name I can cite. The Deny Sids below are names I wrote. They are not AWS-managed policy names. If you do not have Organizations, skip this section. Do not invent an SCP on a standalone account.
Three statements that match what this page already argued:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyLeaveOrganization",
"Effect": "Deny",
"Action": ["organizations:LeaveOrganization"],
"Resource": "*"
},
{
"Sid": "DenyCreateAccessKey",
"Effect": "Deny",
"Action": ["iam:CreateAccessKey", "iam:CreateUser"],
"Resource": "*"
},
{
"Sid": "DenyOutsideHomeRegions",
"Effect": "Deny",
"NotAction": [
"iam:*",
"organizations:*",
"route53:*",
"cloudfront:*",
"sts:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["us-east-1", "us-west-2"]
}
}
}
]
}
DenyLeaveOrganization is the action AWS documents when a member should not walk away from the org. DenyCreateAccessKey is the enforcement half of jacurtis’s comment: the console can nag, the SCP can refuse. DenyOutsideHomeRegions is a coarse Region lock. The NotAction list is the usual global-service exception pattern. Test it on a sandbox OU first. The examples page tells you to do that. I have not applied this JSON to your org.
Resource control policies exist now as a second Organizations type. They constrain resource policies, not identities. Mentioning them is honest. Writing one on this page without a live attach would be a guess. Skip RCPs until you can name the resource they bind.
Simulate before you attach
iam:SimulatePrincipalPolicy is the receipt. Point it at AppReadRole and ask whether s3:GetObject on an invoice key is allowed, and whether s3:DeleteObject is denied. Do this in the account you admin.
ROLE_ARN="arn:aws:iam::111122223333:role/AppReadRole"
aws iam simulate-principal-policy \
--policy-source-arn "$ROLE_ARN" \
--action-names s3:GetObject s3:DeleteObject s3:ListBucket \
--resource-arns \
arn:aws:s3:::app-uploads/invoices/2026-08.pdf \
arn:aws:s3:::app-uploads \
arn:aws:s3:::app-uploads/other/secret.txt
Expect allowed on the invoice Get. Expect implicitDeny or explicitDeny on Delete and on other/secret.txt. If Delete is allowed, you attached the wrong policy. If the invoice Get is denied, the Resource ARN or the KMS grant is wrong. Fix that before a task role points here.
Identity Center permission sets can be simulated the same way once you have the role ARN they provision in the account. Do not skip the call because the console wizard was green.
Prove no leftover AKIA
You are listing keys in your account. You are not attacking another tenant.
aws iam generate-credential-reportthenget-credential-report. Every IAM user row should showaccess_key_1_activeasfalseunless you can name the human or the leftover vendor that still needs it.aws iam list-access-keys --user-name leftover-cifor any user you still have. Delete the key or the user.- On the app host,
echo "$AWS_ACCESS_KEY_ID"must be empty.aws sts get-caller-identitymust printAppReadRole, and the access key id in CloudTrail for that call should start withASIA. - Rerun the simulate command from the previous section after every policy edit.
- IAM Access Analyzer unused-access findings are a cleanup queue, not a grant.
aws iam generate-credential-report
aws iam get-credential-report --output text --query Content | base64 --decode | \
awk -F, 'NR==1 || $9=="true" || $14=="true" {print $1,$9,$14}'
aws sts get-caller-identity
aws iam get-access-key-last-used --access-key-id AKIAEXAMPLE
The last command is for a key you already found in your own report. The docs name get-access-key-last-used as the way to see when that key was used. A key that has not been used in 90 days is a delete, not a rotation theatre. Root user keys, if the report shows any, are an incident. Remove them. Identity Center is the daily admin path.
On EC2, the role is only as tight as the metadata hop. Require IMDSv2 and a hop limit of 1 so a container cannot fetch the session through the instance. That is HttpTokens=required and HttpPutResponseHopLimit=1 on the launch template. A role with perfect JSON and IMDSv1 still hands ASIA to any process that can GET 169.254.169.254. The host guide is the process user. This page is the role that user may assume.
The console friction jacurtis described in 2023 is still the product behavior I can cite. The SCP is how you make it a deny instead of a warning. A warning is not a control.
Questions we keep getting
Can the AWS CLI on my laptop still use a user key?
It can. It should not. Identity Center aws sso login issues a session. A laptop key lives in a file that will be copied. If you still have one, scope it with aws:RequestedRegion and a source-IP condition, and put a delete date on the calendar.
Does a permission boundary replace an SCP?
No. A boundary caps one role or user you delegate. An SCP caps an account or OU. You can use both. You cannot skip the identity policy. Neither type grants.
Is AdministratorAccess acceptable on a break-glass role?
Only if assume is MFA-gated, logged, alerted, and time-bounded, and the role is not the app role. AppReadRole never gets that managed policy. Simulate it. If iam:CreateAccessKey is allowed on the app role, you failed the page.



