Multi factor authorization (MFA) for AWS cli (and in the future terraform).

Being in-between jobs at the moment, I decided to set up a private AWS account. Unfortunately, unlike Azure, Amazon does not offer an account with a limited budget. This means that in the worst case you can literally get ruined for life if your credentials are leaked, or if you set up your account incorrectly. Thus, enabling MFA is a must, but that does not automatically cover the CLI. So I’d like to require MFA for CLI access, but without using additional third-party solutions. Moreover, I will run terraform from my local computer and the solution must work with terraform.

Warning: This is a security-relevant topic and this is the first time I’m setting this up - be critical and don’t just copy and paste. Feedback welcome. Also, please note that I tested this for my single, private account - not for a whole organization or for federated users.

MFA with the AWS CLI

The general idea is the following: I want to use an IAM policy condition element to check for the values of the aws:MultiFactorAuthPresent and possibly aws:MultiFactorAuthAge keys. As far as I can tell, we have the following two options:

  1. Create a new IAM role and add the policies you want. Check the condition as part of the trust relationship.
  2. Add the condition check to a special policy which is then added to the IAM user, or to an IAM group to which the user belongs.

Adding the Check as Part of a Role

Prequisites: add a (virtual) MFA device and create permanent credentials for the user. You can follow the official documentation Obviously, the login user should not have any permissions (other than being allowed to assume the role we will create in a second - this may be necessary for federated users).

Create a new IAM role and add the policies you want. Important: the MFA condition check cannot be part of the policies, because when we request temporary credentials via assume-role, they won’t have the key present (docs):

The temporary credentials returned by AssumeRole do not include MFA information in the context, so you cannot check individual API operations for MFA.

Instead, we add the condition check(s) as part of the trust relationship of the IAM role:

{
  "Version": "2012-10-17",
  "Statement": [
      {
          "Sid": "",
          "Effect": "Allow",
          "Principal": {
              "AWS": "arn:aws:iam::<account-id>:user/<user-name>"
          },
          "Action": "sts:AssumeRole",
          "Condition": {
              "Bool": {
                  "aws:MultiFactorAuthPresent": "true"
              },
              "NumericLessThan": {
                  "aws:MultiFactorAuthAge": "3600"
              }
          }
      }
  ]
}

The following local configuration allows us to use this with the aws cli:

  • ~/.aws/config:
[profile login]
# Uses the permanent credentials
output=json

[profile example-role-profile]
source_profile=login
role_arn=arn:aws:iam::<account-id>:role/<role-name>
mfa_serial=arn:aws:iam::<account-id>:mfa/<mfa-name>
  • ~/.aws/credentials:
[login]
aws_access_key_id = <your-access-key-id>
aws_secret_access_key = <your-secret-access-key>

When using the new profile (e.g., aws --profile example-role-profile s3 ls), the AWS cli prompts for the MFA token and stores it to ~/.aws/cli/cache/. Unfortunately, on my computer the caching mechanism somehow does not work and the cli requests a new token for every call.

Moreover, this approach won’t work with terraform, because terraform does not support prompting for a MFA token (favoring automation and all …). Instead, we use a different approach:

  • login profile with permanent credentials - used to request temporary credentials with MFA via an sts assume-role call.
  • login-tmp profile that uses the temporary credentials from above.
  • example-role-profile profile that assumes the role and references login-tmp.

Thus, the configuration:

  • ~/.aws/config:
[profile login]
# Uses the permanent credentials
output = json

[profile login-tmp]
# Used to save the temporary credentials
output = json

[profile example-role-profile]
source_profile = login-tmp
role_arn = arn:aws:iam::<account-id>:role/<role-name>
  • ~/.aws/credentials:
[login]
aws_access_key_id = <your-access-key-id>
aws_secret_access_key = <your-secret-access-key>

We can now request new temporary credentials:

$ aws --profile login sts assume-role \
    --role-arn "arn:aws:iam::<account-id>:role/<role-name>" \
    --serial-number "arn:aws:iam::<account-id>:mfa/<mfa-name>" \
    --role-session-name "cli-session" \
    --token-code "<the-current-token-of-your-mfa>"

{
    "Credentials": {
        "AccessKeyId": "XXX",
        "SecretAccessKey": "XXX",
        "SessionToken": "XXX",
        "Expiration": "2022-02-22T14:25:04+00:00"
    },
    "AssumedRoleUser": {
        "AssumedRoleId": "AROAQBJO3HJRQQNXKMZ76:test",
        "Arn": "arn:aws:sts::<account-id>:assumed-role/<role-name>/cli-session"
    }
}

Now, add these credentials to ~/.aws/credentials:

[login-tmp]
aws_access_key_id = xxx
aws_secret_access_key = xxx
aws_session_token = xxx
expiration = xxx

Using the example-role-profile profile should work now: aws --profile example-role-profile s3 ls.

Renewing Temporary Credentials Automatically

Manually requesting new temporary credentials and adding them by hand is obviously tedious. I use a bash script that does that automatically and add some additional information to the profiles that is only used by the script - ~/.aws/config:

[profile login-tmp]
# Uses temporary login credentials for the user.
output = json
# These keys are only used by our script to request new credentials.
tmp_credentials_mfa_serial = arn:aws:iam::<account-id>:mfa/<mfa-name>
tmp_credentials_permanent_login_profile = login

# As before
[profile example-role-profile]
source_profile = login-tmp
role_arn = arn:aws:iam::<account-id>:role/<role-name>

That’s it. Now:

  1. Update the temporary credentials: aws-update-token example-role-profile
  2. Use the role: aws --profile example-role-profile s3 ls

The script finds the source_profile key and knows this is the profile name of the profile that will hold the temporary credentials. We added additional information to the temporary login profile: the mfa serial and a reference to the login profile with the permanent credentials. That means we only have to pass the role we actually want to use to the script.

Adding the MFA Check as Part of a Policy

There is an alternative approach that does not include a new role. Although the IAM role approach is probably the better one I want to document the second way as well, because I found it confusing that you can find both approaches on the internet when looking for an MFA solution for the cli.

In the second approach, we add the MFA check as part of each policy and attach it directly to the user (group). For example, for a new admin user: create a new AdministratorAccessMFARequired policy and add the MFA check(s) to the policy statement. E.g., for an admin user:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "*",
            "Resource": "*",
            "Condition": {
                "Bool": {
                    "aws:MultiFactorAuthPresent": "true"
                },
                "NumericLessThan": {
                    "aws:MultiFactorAuthAge": "3600"
                }
            }
        }
    ]
}

Then, add this policy either directly to the IAM user, or (probably better) to an IAM group to which the user belongs.

In our ~/.aws/config:

[profile login]
# Authenticates with permanent login credentials and is used to request
# temporary credentials with MFA which are saved to [profile admin]
output = json
region = eu-central-1

[profile admin]
# Uses the temporary credentials (with MFA) to authenticate.

And ~/.aws/credentials:

[login]
aws_access_key_id = <your-access-key-id>
aws_secret_access_key = <your-secret-access-key>

As expected, the login profile does not have the proper permissions:

$ aws --profile login s3 ls

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

Instead, we need to generate temporary credentials using MFA and use them for authentication - but this time with sts get-session-token (see the docs for the difference to sts assume-role) :

$ aws --profile login sts get-session-token --serial-number arn:aws:iam::<account-id>:mfa/<user-name> --token <the-current-token-from-your-virtual-mfa>
{
    "Credentials": {
        "AccessKeyId": "<the-access-key-id>",
        "SecretAccessKey": "<the-secret-access-key>",
        "SessionToken": "<the-session-token>",
        "Expiration": "2022-02-22T04:08:20+00:00"
    }
}

We now add this to our ~/.aws/credentials file:

[admin]
aws_access_key_id = <the-access-key-id>
aws_secret_access_key = <the-secret-access-key>
aws_session_token = <the-session-token>
expiration = <the-expiration-date>

And, voilà, it works and we have elevated permissions:

$ aws --profile admin s3 ls
2022-02-21 15:21:24 my-fancy-bucket-tf-state-eu-central-1

Automating the Renewal of Temporary Credentials

Now, it is of course super annoying to refresh the token manually. Instead, we use the same script as before and again add additional information to ~/.aws/config:

[admin]
# Uses the temporary credentials (with MFA) to authenticate.
# The following keys are only used by our script to request new credentials.
tmp_credentials_mfa_serial = arn:aws:iam::<account-id>:mfa/<mfa-name>
# Reference to the profile with permanent credentials used for requesting the
# new credentials.
tmp_credentials_permanent_login_profile = login

Call the script as aws-update-token admin and it will re-new the token, if necessary. Testing the profile again:

$ aws --profile admin s3 ls
2022-02-21 15:21:24 my-fancy-bucket-tf-state-eu-central-1

And terraform?

This post is already getting too long. Sneak peek: The idea with terraform is to use a Makefile. The Makefile will first call the aws-update-token script to make sure the credentials are up-to-date and then run terraform. An alternative approach would be to use the credential_process key in the profile that calls out to a script which gets the credentials and then echoes the credentials as json on stdout. See the official docs for an explanation of the format the script would have to emit. I was hoping to simply use the mfa_serial approach with an internal script, but terraform just hung and didn’t show the script MFA prompt either.

Conclusion

I documented two ways of using MFA for the CLI:

  1. Using a role that checks MFA authorization as part of the trust relationship. AWS cli supports this out of the box with the mfa_serial key. But the credential caching did not work for me personally, plus terraform does not support the approach. Instead, I request temporary credentials and write them to a login-tmp profile.
  2. Add the MFA authorization check as part of the policies. Attach the policies to an IAM group and add the user to it. The AWS cli does not support this scenario, but we can still request temporary credentials and store them in a profile.

To handle the request of temporary credentials I wrote a script that takes care of it. I store additional metadata as part of the profile defintions (the MFA serial and a reference to the login profile with permanent credentials) which allows me to call the script with only the profile name I want to use. See the repository for example files.

Feedback welcome - you can reach me via mastodon: @jmehne@fosstodon.org

References