Cross-Account Secret Access with AssumeRole
Two AWS accounts, one secret: the shared-services account owns a database credential, and a workload in another account needs to read it. Copying the secret creates a second thing to rotate and leak. The correct pattern is to assume a scoped IAM role in the secret’s account with STS, read the secret with the temporary credentials, and let them expire. This extends AWS Secrets Manager integration and the secrets management discipline.
Cross-account access is unusual in AWS because it requires agreement from both sides, and that is a feature rather than an obstacle. The role’s trust policy, in the secret’s account, states who may assume it; the caller’s IAM policy states that it may call sts:AssumeRole on that role. Neither account can unilaterally grant the other access, so no single misconfiguration opens a path between accounts.
Problem 1: sharing a long-lived access key
# ANTI-PATTERN — a static key from the other account, baked into config
import boto3
client = boto3.client(
"secretsmanager",
aws_access_key_id="AKIA...", # long-lived, cross-account, a durable liability
aws_secret_access_key="...",
)
A shared static key is a permanent credential in a second account — exactly what you are trying to avoid. It is also the hardest kind of credential to retire, because whoever issued it usually cannot tell who is still using it: an IAM user’s key appears in CloudTrail as the same principal no matter which service or person holds it, so “can we rotate this?” has no safe answer.
There is a supply-chain dimension too. The key has to reach the workload somehow, and every mechanism that carries it — a manifest, a CI variable, a config file — becomes a place a cross-account credential lives. AssumeRole removes the question entirely: the workload’s own identity is the input, and nothing durable ever crosses the account boundary — only a signed request and a short-lived session.
Problem 2: copying the secret into your account
Duplicating the secret means two copies to rotate in lockstep; miss one and services drift onto a stale credential. Keep a single source of truth and grant access instead.
The drift is not hypothetical, and it is worse than a plain stale value. Automatic rotation updates the original; the copy keeps whatever it had, so after the first rotation the two accounts hold different credentials that are both syntactically valid. Services in the copying account fail authentication while services in the owning account work perfectly, which sends the investigation looking at networking or IAM rather than at a copy nobody remembered existed.
Copies also multiply the audit problem. Reads of the original are logged in the owner’s CloudTrail; reads of the copy are logged in the copier’s, with no link between them. Answering “who has access to this credential?” then requires enumerating every account that might hold a copy — which is exactly the question a single source of truth answers with one query against one account’s trail.
Secure implementation
Assume a role scoped to the one secret, then build a client from the temporary credentials.
# cross_account.py
import boto3
from pydantic import SecretStr
def read_cross_account_secret(role_arn: str, secret_id: str, region: str) -> SecretStr:
sts = boto3.client("sts")
creds = sts.assume_role(
RoleArn=role_arn, # role in the secret's account
RoleSessionName="config-loader", # shows up in CloudTrail for audit
)["Credentials"]
sm = boto3.client(
"secretsmanager",
region_name=region,
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"], # temporary, auto-expiring
)
value = sm.get_secret_value(SecretId=secret_id)["SecretString"]
return SecretStr(value)
The role’s trust policy names your account; its permission policy grants only secretsmanager:GetSecretValue on that one secret ARN.
RoleSessionName is more useful than it looks. It appears in the session’s ARN and in every CloudTrail event, so a descriptive name — payments-api-config-loader rather than session1 — turns the owning account’s audit log into something readable. When several workloads assume the same role, the session name is the only thing distinguishing them, so treat it as a required field rather than a placeholder. Including the environment as well as the service — payments-api-prod-config-loader — costs nothing and means a read from the wrong environment is visible in the log rather than needing to be inferred from timing.
The trust policy is where the real configuration lives, and it deserves to be tighter than the common example:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::111122223333:role/payments-api"},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {"sts:ExternalId": "payments-api-prod"},
"DateLessThan": {"aws:TokenIssueTime": "${aws:CurrentTime}"}
}
}]
}
Naming a specific role ARN rather than the account root is the important narrowing. "AWS": "arn:aws:iam::111122223333:root" trusts any principal in that account that also has sts:AssumeRole permission — which delegates the decision entirely to the other account’s IAM administrators. Naming the role means both accounts must act for a new principal to gain access.
Caching sessions as well as secrets
The naive implementation calls AssumeRole on every secret read, which adds an STS round-trip to each fetch and generates a CloudTrail event per call. Since the temporary credentials are valid for the whole session duration, reusing them until they near expiry is straightforward and removes most of that cost.
# cross_account.py — reuse the session until it is close to expiring
import datetime as dt
_session: tuple[dt.datetime, dict] | None = None
SKEW = dt.timedelta(minutes=5)
def _assume(role_arn: str, session_name: str, external_id: str) -> dict:
global _session
now = dt.datetime.now(dt.timezone.utc)
if _session and _session[0] - SKEW > now:
return _session[1] # still comfortably valid
resp = boto3.client("sts").assume_role(
RoleArn=role_arn,
RoleSessionName=session_name,
ExternalId=external_id,
DurationSeconds=900, # ask for the minimum you need
)
_session = (resp["Credentials"]["Expiration"], resp["Credentials"])
return _session[1]
Two choices are worth defending. Requesting the minimum useful DurationSeconds rather than the maximum keeps a leaked session short-lived; fifteen minutes is plenty when the secret itself is cached for ten. And the five-minute skew margin means a session is replaced before it expires rather than at the moment a call fails, which avoids an error path that would otherwise fire rarely and be poorly tested.
Note that Expiration from STS is an absolute, timezone-aware datetime from the server, not a duration — so unlike the TTL caches elsewhere in this section, wall-clock comparison is correct here. The value comes from AWS, so local clock drift affects the comparison rather than the credential’s actual validity, which is another reason for a generous skew margin.
An alternative worth knowing: boto3 can refresh assumed-role credentials automatically if you construct the session through botocore’s credential providers rather than passing keys manually. That is less code and handles refresh transparently, at the cost of a less obvious mechanism. Either approach is fine; what matters is that the session is not re-assumed per call and not held past its expiry.
What ExternalId is actually for
ExternalId looks like a redundant shared secret and is not one. It exists to prevent the confused deputy problem, which is worth understanding because the condition is otherwise easy to dismiss as ceremony.
The scenario is specific to a shared-services account that grants access to several tenants. Suppose the platform account’s role trusts account A and account B, each identifying itself by account number. If account A can somehow induce the platform’s own automation to call AssumeRole on its behalf — by supplying a role ARN as configuration, say — it may gain access intended for account B. The deputy (the platform) is confused about whose behalf it is acting on.
ExternalId fixes this by adding a value that the trusting account chooses and the calling account must present. The caller cannot guess or supply it themselves, so an induced call without the right ExternalId fails. It is not a secret in the credential sense — it does not need protecting the way a key does — but it must be unique per tenant and assigned by the account granting access.
creds = sts.assume_role(
RoleArn=role_arn,
RoleSessionName="payments-api-config-loader",
ExternalId="payments-api-prod", # assigned by the secret's account
DurationSeconds=900,
)["Credentials"]
For two accounts you own, the confused-deputy risk is largely theoretical and ExternalId is cheap insurance. For a role you offer to third parties — a vendor reading your secrets, or your service reading a customer’s — it is essential, and any reputable vendor will insist on it. Treat its presence as a signal that whoever designed the integration understood the threat model.
Gotchas & version-specific behaviour
- The assumed role’s permission policy should scope to the exact secret ARN, not
*. RoleSessionNameappears in CloudTrail, so use a descriptive name for auditing.- Temporary credentials expire; cache the fetched secret in memory with a TTL rather than re-assuming per request.
- The secret’s resource policy in the owning account may also need to allow the role.
- Prefer an OIDC or instance role as the base identity so no static key exists anywhere.
- A customer-managed KMS key is required for cross-account reads — the AWS-managed key cannot be shared — and its key policy must permit the assuming role.
- Role chaining caps the session at one hour regardless of
DurationSeconds, so a role assumed from another assumed role cannot hold a longer session.
The KMS requirement catches nearly every first attempt. The IAM policy is right, the trust policy is right, the resource policy is right, and the call still fails — because the secret is encrypted with the account’s default AWS-managed key, which by design cannot be used by another account at all. The fix is to create a customer-managed key, re-encrypt the secret with it, and grant the assuming role kms:Decrypt in the key policy.
Role chaining is the other constraint worth planning around rather than discovering. If the workload’s own identity is already an assumed role — which it is on EC2, ECS, EKS, and Lambda — then assuming a second role is chaining, and AWS caps the resulting session at one hour no matter what DurationSeconds requests. That is rarely a problem for a short-lived secret read, and it is a real one for a long-running job that expected an eight-hour session; in that case the job must re-assume periodically rather than assuming once at startup.
Production parity checklist
- Grant
secretsmanager:GetSecretValueon the specific secret ARN only. - Use STS AssumeRole, never a shared static access key.
- Give the role a short maximum session duration.
- Cache the secret with a TTL and wrap it in
SecretStr. - Alarm on
AssumeRolefailures and unexpectedGetSecretValuecallers in CloudTrail. - Name a specific role ARN in the trust policy, not the account root, and require an
ExternalId.
Frequently asked questions
How do I read a Secrets Manager secret from another AWS account?
Assume a role in the secret’s account with STS AssumeRole, then create a Secrets Manager client from the temporary credentials and call get_secret_value. The role’s trust policy names your account and its permission policy grants secretsmanager:GetSecretValue on the specific secret.
Should I copy the secret into my own account instead?
No. Copying creates a second place to rotate and a second thing to leak. Cross-account AssumeRole keeps one source of truth and grants time-limited, scoped access instead.
How long do assumed-role credentials last?
STS returns temporary credentials whose lifetime you request, from 15 minutes up to the role’s maximum session duration. Fetch the secret, cache it with a TTL, and let the STS credentials expire.
Key takeaways
Grant cross-account access with a scoped STS AssumeRole, not a copied secret or a shared static key; read the secret with the temporary credentials, cache it as SecretStr with a TTL, and let the session expire. The configuration that makes this safe lives in three places and all three must line up: the caller’s permission to assume, the trust policy naming that exact role with an ExternalId, and the KMS key policy permitting decryption — the last of which is the one that fails first and explains itself least.
Cache the STS session and the secret separately, since they expire for different reasons, and request the shortest session duration that works. Then the only thing crossing the account boundary is a request, and the only thing that outlives it is a value in memory with a TTL. Compared with a copied secret — which drifts at the first rotation and hides its own existence — or a shared access key, which nobody can safely retire because nobody knows who holds it, that is a substantially smaller thing to have to reason about later.