Sitemap

AWS Cross-Account Credential Management

7 min readJul 4, 2026

--

Access control across AWS accounts is something I’m encountering every now and then and it’s an interesting topic that adds some intricacies to the usual AWS setup. Not always we can rely on clients being able to use IAM auth, especially when operating in abstracted environments such as Kubernetes so the need to share explicit credentials can arise. Not considering a solution where a Third party store like Vault is being used we are going to concentrate on as much AWS native services as possible to facilitate access control. As an example for a service that can be easily used for a proof of concept we will use AWS API Gateway with API Keys, and store those API Keys encrypted with a custom KMS Key in AWS Secrets Manager and we will be looking at scenarios where a Producer account provides an API that a Consumer account wants to access properly.

Solution 1: Pull

The first solution we are going to look at is the Pull model. In this case, the Producer is responsible for creating the credential store, sharing it with the Consumer and keeping the credentials up to date.

Producer

We start by creating an API Gateway with a Mock integration that will always return a 200 and require an API key. For this we will use CDK written in Golang:

restApi := awsapigateway.NewRestApi(stack, jsii.String("API"), &awsapigateway.RestApiProps{})
restApi.Root().AddMethod(jsii.String("GET"), awsapigateway.NewMockIntegration(&awsapigateway.IntegrationOptions{
IntegrationResponses: &[]*awsapigateway.IntegrationResponse{{
StatusCode: jsii.String("200"),
},
},
PassthroughBehavior: awsapigateway.PassthroughBehavior_NEVER,
RequestTemplates: &map[string]*string{
"application/json": jsii.String("{ \"statusCode\": 200 }"),
},
}), &awsapigateway.MethodOptions{
ApiKeyRequired: jsii.Bool(true),
MethodResponses: &[]*awsapigateway.MethodResponse{
{
StatusCode: jsii.String("200"),
},
},
})

awsapigateway.NewDeployment(stack, jsii.String("Deployment"), &awsapigateway.DeploymentProps{
Api: restApi,
})

even though this API gets deployed, calling it will fail because we’re missing an API Key at the moment which we are adding next:

apiKey := awsapigateway.NewApiKey(stack, jsii.String("APIKey"), &awsapigateway.ApiKeyProps{})

usagePlan := restApi.AddUsagePlan(jsii.String("UsagePlan"), &awsapigateway.UsagePlanProps{
ApiStages: &[]*awsapigateway.UsagePlanPerApiStage{
{Api: restApi, Stage: restApi.DeploymentStage()},
},
})
usagePlan.AddApiKey(apiKey, &awsapigateway.AddApiKeyOptions{})

this will create an API Key and attach it to a Usage Plan which is attached to the API Gateway stage. Calling the API with this API Key should now return the mock result above.

Next, we will create a KMS Key and a Secret that will hold the API Key in the future:

consumerAccount := "ACCOUNT_ID"
principalArnWildcard := fmt.Sprintf("arn:aws:iam::%s:role/ConsumerStack-*", consumerAccount)

key := awskms.NewKey(stack, jsii.String("Key"), &awskms.KeyProps{
RemovalPolicy: awscdk.RemovalPolicy_DESTROY,
})
key.Policy().AddStatements(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("kms:Decrypt"),
},
Conditions: &map[string]interface{}{
"ArnLike": &map[string]interface{}{
"aws:PrincipalArn": jsii.String(principalArnWildcard),
},
},
Principals: &[]awsiam.IPrincipal{
awsiam.NewStarPrincipal(),
},
Resources: &[]*string{
jsii.String("*"),
},
}))

secret := awssecretsmanager.NewSecret(stack, jsii.String("Secret"), &awssecretsmanager.SecretProps{
EncryptionKey: key,
})
secret.AddToResourcePolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("secretsmanager:GetSecretValue"),
},
Conditions: &map[string]interface{}{
"ArnLike": &map[string]interface{}{
"aws:PrincipalArn": jsii.String(principalArnWildcard),
},
},
Principals: &[]awsiam.IPrincipal{
awsiam.NewStarPrincipal(),
},
Resources: &[]*string{
jsii.String("*"),
},
}))

Here we see the first speciality of cross-account Secret access: The need to explicitly specify the Consumer accounts’ principal that will get access to the Key and the Secret. If the Consumer setup would exist already (in a “dummy” form waiting for the Producer setup to be created) we could use the ARN of the Consumer directly but a more flexible alternative is to use a wildcard Principal and restrict via an IAM Condition which allows the Producer to be created without a Consumer when both have agreed on a pattern of ARN to be used.

Get Johannes Geiger’s stories in your inbox

Join Medium for free to get updates from this writer.

Finally we want to ensure that the API Key is automatically updating the Secret and for this we first create a Synchronize Lambda:

synchronizeFunction := awscdklambdagoalpha.NewGoFunction(stack, jsii.String("Synchronize"), &awscdklambdagoalpha.GoFunctionProps{
FunctionName: jsii.String("Synchronize"),
LogGroup: awslogs.NewLogGroup(stack, jsii.String("SynchronizeLogGroup"), &awslogs.LogGroupProps{
LogGroupName: jsii.String("SynchronizeLogGroup"),
RemovalPolicy: awscdk.RemovalPolicy_DESTROY,
}),
Entry: jsii.String("synchronize"),
})

ensure it has access to all relevant resources:

apiKey.GrantRead(synchronizeFunction)
//...
key.GrantEncrypt(synchronizeFunction)
// ...
secret.GrantWrite(synchronizeFunction)

and we finally call the Lambda as part of the CDK Stack:

synchronizeApiKeyValue := customresources.NewAwsCustomResource(stack, jsii.String("SynchronizeAPIKeyValue"), &customresources.AwsCustomResourceProps{
OnCreate: &customresources.AwsSdkCall{
Service: jsii.String("Lambda"),
Action: jsii.String("invoke"),
Parameters: &map[string]interface{}{
"FunctionName": jsii.String("Synchronize"),
"Payload": jsii.Sprintf("{\"apiKeyId\":\"%s\",\"secretId\":\"%s\"}", *apiKey.KeyId(), *secret.SecretArn()),
},
PhysicalResourceId: customresources.PhysicalResourceId_Of(apiKey.KeyId()),
},
Policy: customresources.AwsCustomResourcePolicy_FromStatements(&[]awsiam.PolicyStatement{
awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("lambda:InvokeFunction"),
},
Resources: &[]*string{
synchronizeFunction.FunctionArn(),
},
}),
}),
})
synchronizeApiKeyValue.Node().AddDependency(synchronizeFunction, apiKey, secret)

and afterwards we should be able to verify that the Secret contains indeed the API Key value.

Consumer

The Consumer then just needs to make sure that the Identity policy of the Lambda has access to the KMS Key and Secret above:

keyArn := "KEY_ARN"
key := awskms.Key_FromKeyArn(stack, jsii.String("Key"), jsii.String(keyArn))

secretArn := "SECRET_ARN"
secret := awssecretsmanager.Secret_FromSecretCompleteArn(stack, jsii.String("Secret"), jsii.String(secretArn))

url := "API_GATEWAY_URL"
function := awscdklambdagoalpha.NewGoFunction(stack, jsii.String("Consumer"), &awscdklambdagoalpha.GoFunctionProps{
Environment: &map[string]*string{
"SECRET_ID": jsii.String(secretArn),
"URL": jsii.String(url),
},
FunctionName: jsii.String("Consumer"),
LogGroup: awslogs.NewLogGroup(stack, jsii.String("ConsumerLogGroup"), &awslogs.LogGroupProps{
LogGroupName: jsii.String("ConsumerLogGroup"),
RemovalPolicy: awscdk.RemovalPolicy_DESTROY,
}),
Entry: jsii.String("consumer"),
})
key.GrantDecrypt(function)
secret.GrantRead(function, nil)

and invoking the Consumer then should lead to a 200 OK being returned to us.

Solution 2: Push

The second solution, the Push model, will invert the roles a bit. In this case, the Consumer is responsible for creating the credential store, sharing it with the Producer and the Producer will keep the credentials up to date.

Consumer

We first start by setting up the Consumer:

producerAccount := "ACCOUNT_ID"
principalArnWildcard := fmt.Sprintf("arn:aws:iam::%s:role/ProducerStack-*", producerAccount)

key := awskms.NewKey(stack, jsii.String("Key"), &awskms.KeyProps{
RemovalPolicy: awscdk.RemovalPolicy_DESTROY,
})
key.Policy().AddStatements(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("kms:GenerateDataKey"),
jsii.String("kms:Encrypt"),
},
Conditions: &map[string]interface{}{
"ArnLike": &map[string]interface{}{
"aws:PrincipalArn": jsii.String(principalArnWildcard),
},
},
Principals: &[]awsiam.IPrincipal{
awsiam.NewStarPrincipal(),
},
Resources: &[]*string{
jsii.String("*"),
},
}))

secret := awssecretsmanager.NewSecret(stack, jsii.String("Secret"), &awssecretsmanager.SecretProps{
EncryptionKey: key,
})
secret.AddToResourcePolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("secretsmanager:PutSecretValue"),
},
Conditions: &map[string]interface{}{
"ArnLike": &map[string]interface{}{
"aws:PrincipalArn": jsii.String(principalArnWildcard),
},
},
Principals: &[]awsiam.IPrincipal{
awsiam.NewStarPrincipal(),
},
Resources: &[]*string{
jsii.String("*"),
},
}))

url := ""
consumer := awscdklambdagoalpha.NewGoFunction(stack, jsii.String("Consumer"), &awscdklambdagoalpha.GoFunctionProps{
Environment: &map[string]*string{
"SECRET_ID": secret.SecretArn(),
"URL": jsii.String(url),
},
FunctionName: jsii.String("Consumer"),
LogGroup: awslogs.NewLogGroup(stack, jsii.String("ConsumerLogGroup"), &awslogs.LogGroupProps{
LogGroupName: jsii.String("ConsumerLogGroup"),
RemovalPolicy: awscdk.RemovalPolicy_DESTROY,
}),
Entry: jsii.String("consumer"),
})
key.GrantDecrypt(consumer)
secret.GrantRead(consumer, nil)

The overall logic with regards to Wildcard Principals is similar to above, the KMS Key and the Secret are set up with an agreed pattern of how the IAM Roles in the Producer stack will be called and the Consumer Lambda does not yet refer to the “real” URL as it doesn’t exist yet.

Producer

The Producer looks similar to the last scenario:

restApi := awsapigateway.NewRestApi(stack, jsii.String("API"), &awsapigateway.RestApiProps{})
restApi.Root().AddMethod(jsii.String("GET"), awsapigateway.NewMockIntegration(&awsapigateway.IntegrationOptions{
IntegrationResponses: &[]*awsapigateway.IntegrationResponse{{
StatusCode: jsii.String("200"),
},
},
PassthroughBehavior: awsapigateway.PassthroughBehavior_NEVER,
RequestTemplates: &map[string]*string{
"application/json": jsii.String("{ \"statusCode\": 200 }"),
},
}), &awsapigateway.MethodOptions{
ApiKeyRequired: jsii.Bool(true),
MethodResponses: &[]*awsapigateway.MethodResponse{
{
StatusCode: jsii.String("200"),
},
},
})

awsapigateway.NewDeployment(stack, jsii.String("Deployment"), &awsapigateway.DeploymentProps{
Api: restApi,
})

keyArn := "KEY_ARN"
secretArn := "SECRET_ARN"
synchronizeFunction := awscdklambdagoalpha.NewGoFunction(stack, jsii.String("Synchronize"), &awscdklambdagoalpha.GoFunctionProps{
FunctionName: jsii.String("Synchronize"),
LogGroup: awslogs.NewLogGroup(stack, jsii.String("SynchronizeLogGroup"), &awslogs.LogGroupProps{
LogGroupName: jsii.String("SynchronizeLogGroup"),
RemovalPolicy: awscdk.RemovalPolicy_DESTROY,
}),
Entry: jsii.String("synchronize"),
})
synchronizeFunction.Role().AttachInlinePolicy(awsiam.NewPolicy(stack, jsii.String("SynchronizeRolePolicy"), &awsiam.PolicyProps{
Statements: &[]awsiam.PolicyStatement{
awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("kms:GenerateDataKey"),
jsii.String("kms:Encrypt"),
},
Resources: &[]*string{
jsii.String(keyArn),
},
}),
awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("secretsmanager:PutSecretValue"),
},
Resources: &[]*string{
jsii.String(secretArn),
},
}),
},
}))

apiKey := awsapigateway.NewApiKey(stack, jsii.String("APIKey"), &awsapigateway.ApiKeyProps{})
apiKey.GrantRead(synchronizeFunction)

usagePlan := restApi.AddUsagePlan(jsii.String("UsagePlan"), &awsapigateway.UsagePlanProps{
ApiStages: &[]*awsapigateway.UsagePlanPerApiStage{
{Api: restApi, Stage: restApi.DeploymentStage()},
},
})
usagePlan.AddApiKey(apiKey, &awsapigateway.AddApiKeyOptions{})

synchronizeApiKeyValue := customresources.NewAwsCustomResource(stack, jsii.String("SynchronizeAPIKeyValue"), &customresources.AwsCustomResourceProps{
OnCreate: &customresources.AwsSdkCall{
Service: jsii.String("Lambda"),
Action: jsii.String("invoke"),
Parameters: &map[string]interface{}{
"FunctionName": jsii.String("Synchronize"),
"Payload": jsii.Sprintf("{\"apiKeyId\":\"%s\",\"secretId\":\"%s\"}", *apiKey.KeyId(), secretArn),
},
PhysicalResourceId: customresources.PhysicalResourceId_Of(apiKey.KeyId()),
},
Policy: customresources.AwsCustomResourcePolicy_FromStatements(&[]awsiam.PolicyStatement{
awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("lambda:InvokeFunction"),
},
Resources: &[]*string{
synchronizeFunction.FunctionArn(),
},
}),
}),
})
synchronizeApiKeyValue.Node().AddDependency(synchronizeFunction, apiKey)

with the main difference that the KMS Key and Secret ARN are now coming from the Consumer stack. Other than that, the Synchronize Lambda again takes care of writing the API Key value to the Secret and after a deployment the Consumer Secret should contain the correct value.

Consumer

Finally, changing the Consumer stack by adding the newly created URL of the API Gateway allows us to invoke the Consumer and get the same 200 OK as above.

Summary

Both Pull and Push models work similar, accessing Secrets across account boundaries works fine. The decision where to eventually place the Secret is not clear cut as in both scenarios the stacks require knowledge of the respective other account and of course, Pull and Push could also even be combined by having a dedicated Credential account that is being written to by the Producer and read from by the Consumer. We haven’t covered the topic of notifying the Consumer on Secret changes but that could be easily done as part of the Synchronize Lambda, e.g. by sending a message to an SNS topic or similar.

Appendix

Synchronize

package main

import (
"context"

"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/apigateway"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/jsii-runtime-go"
)

type Request struct {
ApiKeyId string `json:"apiKeyId"`
SecretId string `json:"secretId"`
}

type Handler struct {
ApiGatewayClient *apigateway.Client
SecretsManagerClient *secretsmanager.Client
}

func (handler *Handler) handleRequest(ctx context.Context, event Request) error {
getApiKeyOutput, err := handler.ApiGatewayClient.GetApiKey(ctx, &apigateway.GetApiKeyInput{
ApiKey: aws.String(event.ApiKeyId),
IncludeValue: jsii.Bool(true),
})
if err != nil {
return err
}

_, err = handler.SecretsManagerClient.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{
SecretId: aws.String(event.SecretId),
SecretString: aws.String(*getApiKeyOutput.Value),
})
if err != nil {
return err
}

return nil
}

func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
panic(err)
}

apiGatewayClient := apigateway.NewFromConfig(cfg)
secretsManagerClient := secretsmanager.NewFromConfig(cfg)

handler := Handler{
ApiGatewayClient: apiGatewayClient,
SecretsManagerClient: secretsManagerClient,
}

lambda.Start(handler.handleRequest)
}

Consumer

package main

import (
"context"
"io"
"net/http"
"net/url"
"os"

"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/jsii-runtime-go"
)

type Handler struct {
SecretId string
Client *secretsmanager.Client
Url string
}

func (handler *Handler) handleRequest(ctx context.Context) (string, error) {
getSecretValueOutput, err := handler.Client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: jsii.String(handler.SecretId),
})
if err != nil {
return "", err
}

client := http.Client{}

parsedUrl, err := url.Parse(handler.Url)
if err != nil {
return "", err
}

request := http.Request{
Method: "GET",
URL: parsedUrl,
Header: map[string][]string{
"x-api-key": {*getSecretValueOutput.SecretString},
},
}
response, err := client.Do(&request)
if err != nil {
return "", err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
}
}(response.Body)

return response.Status, nil
}

func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
panic(err)
}

client := secretsmanager.NewFromConfig(cfg)

handler := Handler{
SecretId: os.Getenv("SECRET_ID"),
Client: client,
Url: os.Getenv("URL"),
}

lambda.Start(handler.handleRequest)
}

--

--