Secure access from AWS CLI with Cross Account Access and MFA

In this article I will demonstrate, how you can access your AWS resources from the command line, when your organization enforces good security practices, such as multi-factor authentication (MFA) and cross account roles. If you are not familiar with this setup, please read Using Multi-Factor Authentication (MFA) in AWS and Delegate Access Across AWS Accounts Using IAM Roles before proceeding. It is also a good practice to limit the lifetime of individual tokens, which provides access to production and development resources.

What problem are we solving?

The problem we are solving here, is how to easily and securely manage users' access to AWS resources in multi-account environments and to allow the use of command line tools, such as AWS Command Line Interface and Serverless Framework.

The overall flow is described in the below diagram: process flow diagram

Prerequisites

In order to follow along, there a few things you need to have:

  1. At least two AWS accounts (for argument's sake let's call them an authentication account and a production account from here on), with MFA (virtual device, such as Duo Mobile) and cross account access set up.
  2. A laptop with Python 3.6+ installed.
  3. Zsh, bash or any other shell of your choice.

Note! This setup is intended for macOS and Linux environments, and is not tested on Windows.

Install and configure the AWS CLI

Follow the official tutorials:

  1. Installing the AWS CLI
  2. Configuring the AWS CLI

When configuring the CLI, use the authentication account's Access keys for AWS Access Key ID and AWS Secret Access Key. The Access keys are defined under Security credentials of the user information on the IAM console.

Note: With this setup alone, you cannot access resources from production account. You can verify this by running:

# Try to list S3 buckets
aws s3 ls

Your access is denied, because of two reasons:

  1. You haven't yet defined the account which have the resources.
  2. You would not have right to access the resources anyway.

Setup MFA and Cross Account Access

How can we tell the CLI, which account's resources would we like to access? How can we use the MFA token?

When you installed and configured the CLI (above), the process created two files on your local computer, under your home directory:

  • ~/.aws/credentials
  • ~/.aws/config

The credentials file looks something like this:

[default]
aws_access_key_id = XXXXXXXXXXX
aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXXXXXXXXX

... and the config file looks like so:

[default]
region = eu-west-1
output = json

Now, to set up MFA and production account access, we need to edit the ~/.aws/config file. Open the file in the text editor of your choice, and add a new section to the configuration:

[profile production]
role_arn = arn:aws:iam::XXXXXXXXXX:role/Role-Name-To-Assume
source_profile = mfa
region = eu-west-1
output = json

You can choose the profile name, in my example it is production. Same applies to region and output. Just keep the source_profile set to mfa. The role_arn will be the arn of the IAM role from the production account, you will assume. You can copy that from the production account's IAM console.

What else do we need? Oh right, the access token!

Get the short-lived access token

Because "NOT INVENTED HERE" is a thing, I made a small Python app, which uses The AWS Security Token Service (STS) API to create (or "update") the tokens.

The app uses Boto3 to invoke the STS API, and the default profile to create new short-lived access tokens.

"""
rotator.py

Rotate AWS credentials, when MFA and role-based (assume role) access are in use.

Python requirements:
- boto3
- Click>=7.0

Run:
python3 rotator.py [aws-credentials-file] [MFA-device-arn] [MFA-token-from-device]

References:
https://docs.python.org/3/library/configparser.html
https://aws.amazon.com/premiumsupport/knowledge-center/authenticate-mfa-cli/
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sts.html#STS.Client.get_session_token
"""

import configparser
import os.path
import sys

import boto3
import click

''' Allowed values between 900 - 43200 sec (12 hrs). '''
TOKEN_DURATION = 43200
REGION = 'eu-west-1'


def get_tokens(mfa_device_arn=None, mfa_code=None):
    """ Get new session tokens with AWS Security Token Service.
        The default profile is used to get new tokens.
    """
    session = boto3.Session(profile_name='default')
    client = session.client('sts')

    response = client.get_session_token(
        DurationSeconds=TOKEN_DURATION,
        SerialNumber=mfa_device_arn,
        TokenCode=mfa_code
    )

    tokens = {
        'output': 'json',
        'region': REGION,
        'aws_access_key_id': response['Credentials']['AccessKeyId'],
        'aws_secret_access_key': response['Credentials']['SecretAccessKey'],
        'aws_session_token': response['Credentials']['SessionToken']
    }

    print(f"Token expiration: {response['Credentials']['Expiration']}")

    return tokens


@click.command()
@click.argument('credentials_file')
@click.argument('mfa_device_arn')
@click.argument('mfa_code')
def rotate(credentials_file, mfa_device_arn, mfa_code):
    """ Rotate sessions tokens for AWS CLI. """

    ''' Check that the required parameters have values. '''
    if not os.path.isfile(credentials_file):
        print('Credentials file is missing!')
        sys.exit()
    if not mfa_device_arn.startswith('arn:aws:iam:'):
        print('MFA Device ARN should have a correct value.')
        sys.exit()
    if len(mfa_code) != 6:
        print('MFA Code should contain 6 characters.')
        sys.exit()

    ''' Get the new session tokens from AWS. '''
    tokens = get_tokens(mfa_device_arn, mfa_code)

    ''' Set the new tokens to credentials config file. '''
    config = configparser.ConfigParser()
    config.read(credentials_file)

    config['mfa'] = tokens

    with open(credentials_file, 'w') as configfile:
        config.write(configfile)

    print('New session tokens have been set successfully.')

    sys.exit()


if __name__ == '__main__':
    rotate()

To run the app, install the Python requirements, preferably in a virtualenv:

pip install Click>=7.0 boto3

After that, run the app with the required arguments:

python rotator.py [aws-credentials-file] [MFA-device-arn] [MFA-token-from-device]
  • aws-credentials-file is usually ~/.aws/credentials
  • MFA-device-arn can be obtained from the authentication account's IAM console, under user settings.
  • MFA-token-from-device is a token from the MFA Virtual Device.

When successfully run, the app will add (or update) a section in the ~/.aws/credentials file like so:

[mfa]
output = json
region = eu-west-1
aws_access_key_id = XXXXXXXXXXXXXX
aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXXXXXXXXX
aws_session_token = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

And voilà, now we should be able to access the production account's resources for the next 12 hours. Let's try:

# Try to list S3 buckets
aws s3 ls

Now this command should list the S3 buckets in the production account.

When the tokens expire, the AWS API will return access error, just as it did in the beginning. When this occurs, just re-run the Python app to generate new tokens.

What about the Serverless Framework?

This same setup will work with the Serverless Framework as well, and actually with many other CLI tools relying on the AWS CLI.

Once again, going with a simple option, you can define the profile while invoking the sls commands as so:

sls info --aws-profile production

Conclusion

With this setup, we are able to access the production account's resources with a user defined only in authentication account, multi-factor authentication, an managed cross account access, and relatively short-lived tokens. The presented setup is quite easy to extend to enable multiple accounts, variable token lifetime, and whatnot.

Bonus tip

If you use this setup with AWS JavaScript SDK, Kubernetes cluster manager kops, or that sorts of tools, you probably will need to set environment variable AWS_SDK_LOAD_CONFIG=1 in order for these tools to use ~/.aws/config in the first place.