Guide · AWS Secrets Manager
Cross-Account Secrets Access for MCP Servers
Multi-account AWS architectures are the norm for enterprise MCP deployments — a central security account holds shared secrets (OAuth client credentials, internal API keys) while application accounts run the Lambda functions that need them. Cross-account secret access requires a coordination of three separate permission systems, all of which must be correct simultaneously. The single most common error: granting kms:Decrypt only in the consuming account's IAM policy, but not in the KMS key policy in the secret-owner account — KMS evaluates both policies, and both must allow the action. The AWS-managed key (aws/secretsmanager) cannot have its key policy modified at all, which means it is categorically unable to support cross-account decryption. A customer-managed KMS key is required. Second common error: using the secret name instead of the full ARN — cross-account GetSecretValue requires the ARN because name resolution is account-local. Third: assuming that a resource-based policy on the secret is sufficient without a corresponding IAM policy in the consuming account — unlike S3, Secrets Manager requires both the resource policy (in the owner account) and an IAM policy (in the consuming account) to permit the action.
TL;DR
Cross-account Secrets Manager access requires three simultaneous grants: (1) a resource-based policy on the secret allowing the consuming account's role, (2) a KMS CMK key policy in the owner account allowing the consuming role's kms:Decrypt, and (3) an IAM policy in the consuming account allowing the role to call secretsmanager:GetSecretValue and kms:Decrypt. All three must be present. Missing any one causes AccessDeniedException.
Why cross-account needs a CMK
Secrets Manager always encrypts secrets at rest using a KMS key. The default key, aws/secretsmanager, is an AWS-managed key — its key policy is entirely controlled by AWS and cannot be modified to add cross-account principals. When a cross-account caller attempts kms:Decrypt using an AWS-managed key, KMS rejects the request regardless of what IAM policies say.
A customer-managed KMS key (CMK) has an explicit key policy that you control. You can add cross-account principals to it:
# Key policy statement to add to the CMK in the OWNER account (e.g., account 111111111111)
{
"Sid": "AllowCrossAccountDecrypt",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::222222222222:role/mcp-lambda-execution-role"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
}
This grants the consuming account's Lambda execution role the ability to decrypt secrets encrypted with this CMK. Without this statement in the key policy, GetSecretValue fails with AccessDeniedException referencing the KMS key — even if the secret's resource policy explicitly allows the caller.
The three-policy requirement
Here is the complete permission chain for cross-account access. Every step must be configured or the call fails:
| Policy location | What to configure | In which account |
|---|---|---|
| Secret resource policy (attached to the secret itself) | Allow secretsmanager:GetSecretValue and secretsmanager:DescribeSecret for the consuming role ARN |
Owner account (where the secret lives) |
| KMS CMK key policy (attached to the encryption key) | Allow kms:Decrypt and kms:DescribeKey for the consuming role ARN |
Owner account (where the CMK lives) |
| IAM role policy (attached to the Lambda execution role) | Allow secretsmanager:GetSecretValue on the specific secret ARN, and kms:Decrypt on the specific CMK ARN |
Consuming account (where the Lambda runs) |
# 1. Secret resource policy (in owner account 111111111111)
aws secretsmanager put-resource-policy \
--secret-id "arn:aws:secretsmanager:us-east-1:111111111111:secret:shared/mcp-credentials-AbCdEf" \
--resource-policy '{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCrossAccountRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::222222222222:role/mcp-lambda-execution-role"
},
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "*"
}]
}'
# 2. CMK key policy addition (in owner account 111111111111) — see previous section
# 3. IAM policy on the Lambda execution role (in consuming account 222222222222)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
"Resource": "arn:aws:secretsmanager:us-east-1:111111111111:secret:shared/mcp-credentials-AbCdEf"
},
{
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "arn:aws:kms:us-east-1:111111111111:key/cmk-key-id-here"
}
]
}
Calling cross-account GetSecretValue from Lambda
Once permissions are configured, calling cross-account GetSecretValue is identical to same-account — use the full ARN:
import boto3
import json
# The Secrets Manager client must be in the SAME REGION as the secret
# (us-east-1 here matches the secret ARN above)
_sm_client = boto3.client("secretsmanager", region_name="us-east-1")
def get_shared_credentials() -> dict:
# Always use the full ARN for cross-account — name resolution is account-local
response = _sm_client.get_secret_value(
SecretId="arn:aws:secretsmanager:us-east-1:111111111111:secret:shared/mcp-credentials-AbCdEf",
VersionStage="AWSCURRENT",
)
return json.loads(response["SecretString"])
The boto3 client uses the Lambda execution role's credentials. The STS service automatically delegates to the cross-account permissions when the client encounters a cross-account ARN. No explicit AssumeRole call is needed if the executing role itself has been granted access — the resource policy + IAM policy combination is sufficient for a direct cross-account call.
Using assume-role for multi-hop cross-account access
For organizations with more than two accounts (e.g., a hub-and-spoke model where a security account holds secrets accessed by 10+ application accounts), granting each application account's role directly on the secret resource policy becomes difficult to maintain. The alternative is to use an intermediate cross-account role in the security account:
import boto3
# STS client in the consuming account
sts = boto3.client("sts", region_name="us-east-1")
def get_cross_account_secrets_client():
"""Assume a role in the owner account and return a Secrets Manager client."""
assumed = sts.assume_role(
RoleArn="arn:aws:iam::111111111111:role/secrets-reader-for-mcp",
RoleSessionName="mcp-server-secrets-session",
DurationSeconds=900, # 15 minutes — minimum session for Secrets Manager reads
)
creds = assumed["Credentials"]
return boto3.client(
"secretsmanager",
region_name="us-east-1",
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
# Cache the temporary client (credentials valid for 15 min) at module level
# Refresh when Expiration is within 60 seconds
import time
_cross_account_client = None
_client_expiry = 0.0
def get_secrets_manager_client():
global _cross_account_client, _client_expiry
if _cross_account_client is None or time.monotonic() > _client_expiry - 60:
_cross_account_client = get_cross_account_secrets_client()
_client_expiry = time.monotonic() + 900
return _cross_account_client
The intermediate role secrets-reader-for-mcp in the owner account needs its trust policy to allow the application account's Lambda execution role to assume it (sts:AssumeRole), and needs IAM policies to call secretsmanager:GetSecretValue and kms:Decrypt in the owner account. This consolidates secret access grants to a single role definition instead of modifying the secret's resource policy for each new application account.
Restricting access by VPC or source IP
For high-security environments, add a condition to the secret's resource policy that restricts cross-account access to requests originating from a specific VPC or VPC endpoint:
{
"Sid": "AllowCrossAccountReadFromVPCOnly",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::222222222222:role/mcp-lambda-execution-role"
},
"Action": "secretsmanager:GetSecretValue",
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:SourceVpc": "vpc-0abc123def456"
}
}
}
aws:SourceVpc is evaluated when the request comes through a VPC endpoint — the Lambda must be deployed in a VPC and the request must route through the Secrets Manager VPC interface endpoint. Without the VPC endpoint, aws:SourceVpc is not populated and the condition evaluates to a deny.
Use aws:SourceVpce (endpoint ID) for even tighter scoping — this restricts access to a single VPC endpoint, preventing a compromise of one account's VPC from accessing secrets intended only for another account's endpoint.
Common failures
| Symptom | Root cause | Fix |
|---|---|---|
AccessDeniedException despite correct secret resource policy |
KMS CMK key policy in owner account does not grant kms:Decrypt to the consuming role |
Add kms:Decrypt and kms:DescribeKey to the CMK key policy for the consuming role ARN |
AccessDeniedException despite correct KMS key policy |
IAM policy in consuming account does not have secretsmanager:GetSecretValue or kms:Decrypt |
Both the resource policy (owner account) AND the IAM policy (consuming account) must allow the action — add the missing permission |
ResourceNotFoundException when calling with correct credentials |
Secret name used instead of full ARN; name lookup is account-local | Use the full secret ARN including account ID and 6-character suffix |
| STS assume-role credentials expire mid-session, causing sporadic auth failures | Assumed role session (15 minutes) expires; module-level client using expired credentials | Track credential expiry time and refresh the assumed-role session before it expires (refresh at expiry minus 60s) |
| Cross-account access works in dev but fails in prod with same role | Prod uses a different VPC or VPC endpoint; resource policy has aws:SourceVpc condition that excludes prod's VPC |
Add prod VPC ID to the aws:SourceVpc condition list; or use aws:PrincipalAccount instead of VPC-based conditions |
Monitor cross-account MCP server health
Multi-account deployments add failure points — a permission change in the security account can silently break all MCP servers in application accounts. AliveMCP probes every endpoint every 60 seconds, independent of your AWS account configuration, and alerts before users notice.
Join the waitlist →