Cross-Account Access to a Shared Bedrock Model
9 min read · updated August 11, 2026
The instinct from S3 is to attach a policy to the thing and name the other account as principal. That is not available here, and knowing why saves an afternoon of writing a policy document that no API will accept.
There is no resource policy on a model
AWS’s “How Amazon Bedrock works with IAM’’ page answers Yes to resource-based policies, and then narrows it in the body: Amazon Bedrock supports resource-based policies for guardrails and guardrail inference profiles. Foundation models are not in that list, and the ARN tells you why — arn:aws:bedrock:us-east-1::foundation-model/model-id has an empty account field. The model is not owned by your account. There is nothing there to attach a policy to.
What is per-account is everything around it: the model agreement, the Marketplace subscription, the service quotas, the bill, any provisioned throughput, and any application inference profile or guardrail you have created. “Sharing a model” between accounts therefore always means one of two things, and they are worth separating before you build:
- Account B has its own access to the same model. Two agreements, two bills, two quota pools. Often the correct answer, and it needs no cross-account anything — just a scoped policy in each account.
- Account B calls through account A. One agreement, one bill, one quota pool, one place where guardrails and logging are enforced. This is the case that needs a role, and it is the one worth building when the centralisation is the point.
The role in the model-owning account
Everything hangs off sts:AssumeRole. In account A — the one with the model agreement — create a role whose trust policy names account B, and whose permissions policy grants only the invocation actions.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::999988887777:role/inference-caller"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "a-secret-string-agreed-out-of-band"
}
}
}
]
}Two choices in that document are deliberate. The principal is a specific role ARN rather than "AWS": "arn:aws:iam::999988887777:root": naming the account root delegates the decision to account B’s administrators, who may then let anything in that account assume your role. And sts:ExternalId is the guard against the confused deputy — it matters most when account B belongs to somebody else, and costs nothing when it does not.
The permissions policy attached to that role is the ordinary least-privilege one: bedrock:InvokeModel* scoped to the model ARNs, plus the inference-profile statement if profiles are in play. Consider adding a condition on aws:PrincipalTag or on sts:RoleSessionName if you intend to attribute cost by caller, which the next section depends on.
Account B needs the matching half. AWS is explicit that a cross-account trust is two-sided: the caller’s own identity policy must allow sts:AssumeRole on that specific role ARN. Granting it on Resource: "*" is a common and bad shortcut — it lets the role assume anything in any account that will have it.
Calling it from the second account
import boto3
sts = boto3.client("sts")
assumed = sts.assume_role(
RoleArn="arn:aws:iam::111122223333:role/bedrock-shared-invoke",
RoleSessionName="search-service", # shows up in CloudTrail
ExternalId="a-secret-string-agreed-out-of-band",
DurationSeconds=3600,
)
creds = assumed["Credentials"]
bedrock = boto3.client(
"bedrock-runtime",
region_name="us-east-1",
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
response = bedrock.converse(
modelId="anthropic.claude-3-haiku-20240307-v1:0",
messages=[{"role": "user", "content": [{"text": "ping"}]}],
)Set RoleSessionName to something meaningful. It is appended to the assumed-role ARN that appears in CloudTrail and in AccessDeniedException messages, so it is the difference between “some role in account B” and “the search service” when you are reading logs six months later.
Do not call assume_role per request. Credentials are valid for DurationSeconds — one hour by default, up to the role’s maximum session duration — and re-assuming on every invocation adds an STS round trip to every model call and will eventually throttle. In production, prefer letting the SDK do it: a profile in ~/.aws/config with role_arn, source_profile and external_id, or in a container the AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE pair, gets you automatic refresh and no credential handling in your code.
Where the cost and the quota land
This is the part that surprises people after the plumbing works. The request is authorised and billed against the account whose credentials signed it — account A. So:
- Account A pays. Every token account B generates appears on account A’s bill, indistinguishable by default from account A’s own usage.
- Account A’s quotas apply. Both accounts now draw from one requests-per-minute and tokens-per-minute pool, so account B can throttle account A — and the resulting ThrottlingException appears in the account that did nothing wrong.
- Attribution needs a deliberate step. Give each consuming account its own role, and its own application inference profile with a cost allocation tag, then have the role’s policy permit only that profile. Now the spend splits in Cost Explorer without anybody being trusted to pass the right tag.
If those consequences are unacceptable, the centralised model is the wrong one and each account should hold its own agreement. That is a real decision, not a fallback: the trade is one bill and one policy point against blast radius and noisy-neighbour throttling.
The one resource policy that does exist
Guardrails are the exception, and it is a purposeful one. AWS documents resource-based policies for guardrails and guardrail inference profiles, recommends them for account-level enforced guardrails, and requires them for organisation-level enforced guardrails.
The reason this exists where model policies do not is worth understanding: a guardrail is a control you want applied to other people’s inference, including inference in accounts you do not administer. That is exactly what a resource policy with a cross-account principal expresses, and it is the mechanism by which a security team can enforce one content policy across an organisation without holding every application’s IAM. If your actual requirement is “central control over what models may be used and how” rather than “central billing”, that combination — an enforced guardrail plus per-account model agreements — gets you the control without the shared quota pool. The guardrails tutorial covers the policy shape.