Skip to content
Huseyin Babal
Go back

AWS IAM for a new account: root MFA, a budget, and an access key that needs a phone

A new AWS account has one identity, the root user, and no spending limit. If an access key with broad rights ends up in a public repo, whoever finds it can start machines on your card until someone deletes the key. This post sets up an account so that a leaked key is close to useless:

Everything below runs with the AWS CLI v2 and jq. The account ID never appears in a command; it’s read into $ACCOUNT and the policy files use ${ACCOUNT} placeholders filled in with envsubst.

ACCOUNT=$(aws sts get-caller-identity | jq -r .Account)

The admin commands in this post need an identity that can manage IAM and Budgets. Run them as your current admin, not as the user created here.

1. Check the root user

The root user is the email address the account was created with. Per AWS’s root user guide, this identity can do anything to any resource in the account, and AWS advises against using it for everyday tasks. A standalone account can’t get rid of its root credentials. Only member accounts inside AWS Organizations can have them removed. So the rules are:

Setting up root MFA happens in the console. Checking it doesn’t:

aws iam get-account-summary | jq '.SummaryMap | {AccountMFAEnabled, AccountAccessKeysPresent}'
{
  "AccountMFAEnabled": 1,
  "AccountAccessKeysPresent": 0
}output

AccountMFAEnabled is about the root user, and AccountAccessKeysPresent counts root access keys. You want 1 and 0.

2. A budget alert

AWS has no hard spending cap. A budget is the closest thing: it watches the bill and sends an email.

budget.json:

{
  "BudgetName": "monthly-10",
  "BudgetType": "COST",
  "TimeUnit": "MONTHLY",
  "BudgetLimit": { "Amount": "10", "Unit": "USD" }
}

notify.json sends an email when the actual cost passes 80% of the limit:

[
  {
    "Notification": {
      "NotificationType": "ACTUAL",
      "ComparisonOperator": "GREATER_THAN",
      "Threshold": 80,
      "ThresholdType": "PERCENTAGE"
    },
    "Subscribers": [
      { "SubscriptionType": "EMAIL", "Address": "you@example.com" }
    ]
  }
]
aws budgets create-budget --account-id $ACCOUNT \
  --budget file://budget.json \
  --notifications-with-subscribers file://notify.json
aws budgets describe-budgets --account-id $ACCOUNT | jq '.Budgets[] | {BudgetName, BudgetLimit}'
{
  "BudgetName": "monthly-10",
  "BudgetLimit": {
    "Amount": "10.0",
    "Unit": "USD"
  }
}output

Two limits to know about. Nothing gets stopped or deleted by the alert. Budget data is refreshed at most three times a day according to the Budgets docs, so expect the email to lag the spending by hours. It still tells you near $8 instead of at the end of the month.

3. How IAM decides, in one paragraph

Every API call is a request with three parts: who is asking (a principal), which action (s3:ListBuckets), and on which resource (an ARN). IAM starts from deny. A request goes through only if some policy allows it, and an explicit Deny anywhere beats every Allow. A policy can sit on a user, a group or a role. A group can’t sign in; it hands its policies to its members. A role has no long-term credentials: someone assumes it and gets temporary credentials that expire. The role’s trust policy says who may assume it, and its permissions policies say what it can do.

Credential types you’ll see below:

CredentialLooks likeExpires
Access keyID starts with AKIA, plus a secretNever, until deleted
Temporary credentialsID starts with ASIA, plus a secret and a session tokenYes, one hour by default for AssumeRole
MFA codeSix digits from an authenticator appEvery 30 seconds

AWS recommends temporary credentials for people, through IAM Identity Center (aws configure sso, then aws sso login) or aws login from an existing console session. If you can use those, do. The setup below is for the common single-developer case where you end up with an access key anyway, and makes that key harmless on its own.

4. The plan

dev-huseyin (access key, no password)
   └─ member of group "developers"
         └─ inline policy: Allow sts:AssumeRole on role/developer
                                        │
                                        ▼
                          role "developer"
                            trust: this account, only if MFA is present
                            permissions: PowerUserAccess

The key can call exactly one action. Everything useful happens as the role, and the role needs an MFA code.

5. The group and its policy

group-policy.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": "arn:aws:iam::${ACCOUNT}:role/developer"
    }
  ]
}
aws iam create-group --group-name developers | jq -r .Group.GroupName
aws iam put-group-policy --group-name developers --policy-name assume-developer \
  --policy-document "$(envsubst < group-policy.json)"
aws iam list-group-policies --group-name developers | jq -r '.PolicyNames[]'
developers
assume-developeroutput

put-group-policy creates an inline policy: it lives inside this group and nowhere else. A managed policy is its own object, reusable across any number of identities. For one rule on one group, inline is fine.

6. The user and its key

aws iam create-user --user-name dev-huseyin | jq -r .User.UserName
aws iam add-user-to-group --user-name dev-huseyin --group-name developers
KEY=$(aws iam create-access-key --user-name dev-huseyin)
aws configure set aws_access_key_id "$(jq -r .AccessKey.AccessKeyId <<< "$KEY")" --profile dev-huseyin
aws configure set aws_secret_access_key "$(jq -r .AccessKey.SecretAccessKey <<< "$KEY")" --profile dev-huseyin
aws configure list --profile dev-huseyin
dev-huseyin
NAME       : VALUE                    : TYPE                    : LOCATION
profile    : dev-huseyin              : manual                  : --profile
access_key : ****************7PKF     : shared-credentials-file :
secret_key : ****************xRIA     : shared-credentials-file :
region     : <not set>                : None                    : Noneoutput

The user has no login profile (no password), so it can’t sign in to the console. Writing the key with aws configure set from a variable keeps the secret off the screen. The key still ends up in ~/.aws/credentials either way. That file is the thing that leaks, which is why the next steps matter.

7. The role, trusted only with MFA

trust.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::${ACCOUNT}:root" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "Bool": { "aws:MultiFactorAuthPresent": "true" }
      }
    }
  ]
}

:root in a principal ARN means the whole account, not the root user. The trust policy lets any identity in this account try, but each identity also needs its own permission to call sts:AssumeRole on this role. Here only the developers group has that. The condition rejects any request that didn’t come with an MFA code.

aws iam create-role --role-name developer \
  --assume-role-policy-document "$(envsubst < trust.json)" | jq -r .Role.RoleName
aws iam attach-role-policy --role-name developer \
  --policy-arn arn:aws:iam::aws:policy/PowerUserAccess
aws iam list-attached-role-policies --role-name developer | jq -r '.AttachedPolicies[].PolicyName'
developer
PowerUserAccessoutput

PowerUserAccess is an AWS managed policy that allows almost every service but leaves out most of IAM and Organizations. The role can run Lambda functions and write to S3, but it can’t create users or edit its own policies to give itself more rights. Pick something narrower once you know which services you use.

8. MFA for the user

A virtual MFA device is a TOTP seed. create-virtual-mfa-device writes it to a file; normally you’d scan it as a QR code (--bootstrap-method QRCodePNG) with an authenticator app. To keep the whole flow in a terminal, this uses the Base32 seed and oathtool as the “phone”:

MFA=$(aws iam create-virtual-mfa-device --virtual-mfa-device-name dev-huseyin \
  --outfile seed.txt --bootstrap-method Base32StringSeed | jq -r .VirtualMFADevice.SerialNumber)
C1=$(oathtool --totp -b "$(cat seed.txt)"); sleep 30; C2=$(oathtool --totp -b "$(cat seed.txt)")
aws iam enable-mfa-device --user-name dev-huseyin --serial-number "$MFA" \
  --authentication-code1 $C1 --authentication-code2 $C2
aws iam list-mfa-devices --user-name dev-huseyin | jq -r '.MFADevices | length'
1output

enable-mfa-device wants two consecutive codes so AWS can line up the device’s clock. Codes change every 30 seconds, hence the sleep 30. Delete seed.txt afterwards; anyone with the seed can generate codes.

Now a profile that assumes the role through the user’s key and asks for the code:

aws configure set role_arn "arn:aws:iam::$ACCOUNT:role/developer" --profile developer
aws configure set source_profile dev-huseyin --profile developer
aws configure set mfa_serial "$MFA" --profile developer

That adds three lines to ~/.aws/config under [profile developer]. The CLI does the AssumeRole call itself and caches the temporary credentials until they expire.

9. Use the key as if it had leaked

cli_error_format json makes the CLI print errors as JSON, so jq can pull out just the error code:

aws configure set cli_error_format json --profile dev-huseyin
aws s3api list-buckets --profile dev-huseyin 2>&1 | jq -r .Code
aws sts assume-role --role-arn "arn:aws:iam::$ACCOUNT:role/developer" \
  --role-session-name test --profile dev-huseyin 2>&1 | jq -r .Code
AccessDenied
AccessDeniedoutput

The first call fails because the user’s only permission is sts:AssumeRole. The second fails at the trust policy: the key is valid, but the request carries no MFA code.

With the developer profile, the CLI prompts for the code:

aws s3api list-buckets --profile developer | jq '.Buckets | length'
aws sts get-caller-identity --profile developer | jq -r '.Arn | split(":") | .[5]'
Enter MFA code for arn:aws:iam::<account>:mfa/dev-huseyin:
9
assumed-role/developer/botocore-session-1794052625output

The caller is now the role, not the user. After an hour the session runs out and the CLI prompts for a fresh code.

10. A deny beats an allow

PowerUserAccess allows all of S3. Add an explicit deny on top and see which one wins.

deny-s3.json:

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Deny", "Action": "s3:*", "Resource": "*" }
  ]
}
aws iam put-role-policy --role-name developer --policy-name deny-s3 \
  --policy-document file://deny-s3.json
aws configure set cli_error_format json --profile developer
aws s3api list-buckets --profile developer 2>&1 | jq -r .Code
aws iam delete-role-policy --role-name developer --policy-name deny-s3
aws s3api list-buckets --profile developer | jq '.Buckets | length'
AccessDenied
9output

The allow was there the whole time. The deny overrode it until it was removed.

11. The same thing from Go

The AWS SDKs read the same files as the CLI. The Go SDK v2’s LoadDefaultConfig looks for credentials in a fixed order: environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN), then the shared files in ~/.aws, then the container or EC2 instance role. None of those is a string in your source code.

package main

import (
	"context"
	"fmt"
	"log"
	"strings"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/sts"
)

func main() {
	ctx := context.Background()

	// same profile as the CLI
	cfg, err := config.LoadDefaultConfig(ctx,
		config.WithSharedConfigProfile("developer"),
		config.WithAssumeRoleCredentialOptions(func(o *stscreds.AssumeRoleOptions) {
			// ask for the MFA code on the keyboard
			o.TokenProvider = stscreds.StdinTokenProvider
		}),
	)
	if err != nil {
		log.Fatal(err)
	}

	id, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
	if err != nil {
		log.Fatal(err)
	}
	arn := aws.ToString(id.Arn)
	fmt.Println("I am:", arn[strings.Index(arn, ":assumed-role")+1:])

	out, err := s3.NewFromConfig(cfg).ListBuckets(ctx, &s3.ListBucketsInput{})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("buckets:", len(out.Buckets))
}

The profile has no region yet, and the SDK needs one:

aws configure set region us-east-1 --profile developer
go run .
Assume Role MFA token code: 711504
I am: assumed-role/developer/aws-go-sdk-1794052890800321986
buckets: 9output

Without a TokenProvider, the SDK doesn’t prompt. It fails with assume role with MFA enabled, but TokenProvider is not set.

A note on programs that run unattended: a cron job or a server can’t type an MFA code, so this profile is only for a person at a keyboard. On EC2, ECS or Lambda, give the workload its own role with only the permissions it needs; the SDK picks the credentials up from the environment and there is no key at all. In CI, GitHub Actions can assume a role through OIDC, again without storing a key.

Cleaning up

If you followed along in a test account, remove everything in reverse order. A user can’t be deleted while it still has keys, MFA devices or group memberships, and a role can’t be deleted while policies are attached.

aws iam deactivate-mfa-device --user-name dev-huseyin --serial-number "$MFA"
aws iam delete-virtual-mfa-device --serial-number "$MFA"
aws iam list-access-keys --user-name dev-huseyin | jq -r '.AccessKeyMetadata[].AccessKeyId' |
  xargs -n1 aws iam delete-access-key --user-name dev-huseyin --access-key-id
aws iam remove-user-from-group --user-name dev-huseyin --group-name developers
aws iam delete-user --user-name dev-huseyin
aws iam delete-group-policy --group-name developers --policy-name assume-developer
aws iam delete-group --group-name developers
aws iam detach-role-policy --role-name developer --policy-arn arn:aws:iam::aws:policy/PowerUserAccess
aws iam delete-role --role-name developer
aws budgets delete-budget --account-id $ACCOUNT --budget-name monthly-10
rm -f seed.txt

Then remove the dev-huseyin and developer sections from ~/.aws/credentials and ~/.aws/config.

Sources


Share this post:

Previous Post
Go encoding/json v1 vs v2 under load: Go 1.26, Go 1.27 and MarshalWrite
Next Post
Google login with Spring Security and React, without a token in the browser