At some point, someone in your company is going to ask for a cross account access and say something like:
“Vendor X just needs read-only access to our AWS account. Can you sort that out?”
They’re usually talking about a cost-optimisation tool, a cloud security scanner, a backup solution, some monitoring platform… you know the type.
The naïve way to solve this is depressingly common: create an IAM user, slap ReadOnlyAccess or even AdministratorAccess on it, generate access keys, and email them to the vendor. Those keys then live forever in someone else’s environment, with no external ID, no real blast-radius control, and no easy way to see what they’re doing.
AWS has had a better pattern for years: cross-account roles plus external IDs. It avoids long-lived keys, limits what the vendor can do, and protects you from the “confused deputy” problem. The problem is that lots of teams know they “should” be using it, but don’t really understand how to wire it up properly.
The real problem you’re solving
You have your AWS account. Your vendor has theirs. Their service needs to make AWS API calls in your account to do its job.
What you want is pretty simple:
- You don’t want to hand out IAM users and long-lived access keys.
- You want to be able to turn off access on your side only.
- You want the vendor constrained to a very specific set of actions.
- You don’t want another customer of that vendor to trick them into accidentally calling into your account.
That last point is the confused deputy problem. The vendor might serve hundreds of customers. Their SaaS backend can assume roles in lots of customer accounts. If there’s a bug or abuse in how they choose which role to assume, you don’t want someone else to be able to talk into your AWS environment just because they share the same provider.
This is exactly what cross-account roles and external IDs are designed to handle.
Cross-account roles and external IDs simplified
The pattern looks like this:
- You create an IAM role in your AWS account.
- That role’s trust policy says:
- “The principal in the vendor’s AWS account is allowed to assume me…
- …but only if they present this specific external ID.”
- The role’s permissions policy says:
- “Once assumed, here’s exactly what you can do.”
- The vendor’s code calls
sts:AssumeRoleagainst that role, passing the external ID. - AWS hands back short-lived credentials for that session.
You never create an IAM user for them. They never store long-lived keys for your account. When you want to revoke or tighten access, you edit or delete the role.
The external ID is just a string generated per customer relationship. AWS checks that the caller provides the value you configured. It’s an extra bit of proof that they’re acting on behalf of your account and not just randomly assuming roles they happen to know the ARN for.
Building a third-party role in your AWS account
To keep this concrete, assume:
- Your AWS account ID:
111122223333 - Vendor’s AWS account ID:
444455556666 - Role name:
ThirdPartyReadOnly - External ID you choose:
cust-111122223333-prod
We’ll build two things:
- A trust policy that lets the vendor assume the role only with that external ID.
- A permissions policy that gives them the minimum AWS permissions they need.
Trust policy with external ID
Here’s a minimal, correct trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::444455556666:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "cust-111122223333-prod"
}
}
}
]
}
A couple of important details:
- The
Principalis the vendor’s AWS account root. That just means any IAM user/role in their account that they allow to callAssumeRoleis eligible. - The
Conditionwithsts:ExternalIdis non-optional. It’s what stops confused-deputy style abuse.
In the typical third-party / SaaS scenario, the vendor generates the external ID and gives it to the customer, not the other way around.
Creating the role via CLI looks like this:
aws iam create-role \
--role-name ThirdPartyReadOnly \
--assume-role-policy-document file://trust-policy.json
Permissions policy: keep it tight
Now decide what the vendor actually needs to do. Let’s say it’s a posture tool that only needs to read EC2 and RDS metadata.
A simple inline policy might look like:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadEC2Metadata",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeVolumes",
"ec2:DescribeTags"
],
"Resource": "*"
},
{
"Sid": "ReadRDSMetadata",
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances",
"rds:DescribeDBClusters",
"rds:ListTagsForResource"
],
"Resource": "*"
}
]
}
Attach it:
aws iam put-role-policy \
--role-name ThirdPartyReadOnly \
--policy-name ThirdPartyReadOnlyPolicy \
--policy-document file://permissions-policy.json
Start narrow. If they’re missing something, extend the policy deliberately. It’s a lot easier to add a handful of actions later than to retrofit least privilege after you’ve given a vendor ReadOnlyAccess or worse.
At this point, your side is ready. You have a role with:
- A trust policy that only allows the vendor’s account, with the external ID.
- A permissions policy that constrains what they can do.
You now send the vendor:
- The role ARN:
arn:aws:iam::111122223333:role/ThirdPartyReadOnly - The external ID:
cust-111122223333-prod
Nothing else.
What the vendor actually does with it
On their side, the vendor simply uses sts:AssumeRole with the ARN and external ID you gave them.
Here’s how it looks with the AWS CLI:
aws sts assume-role \
--role-arn arn:aws:iam::111122223333:role/ThirdPartyReadOnly \
--role-session-name my-customer-session \
--external-id cust-111122223333-prod
If everything is correct, they get temporary credentials back:
AccessKeyIdSecretAccessKeySessionToken- Expiration timestamp
If they drop the --external-id flag or use the wrong value, AssumeRole should fail.
Scaling this to a multi-tenant SaaS
Now imagine the vendor is a SaaS with hundreds of customers. The pattern just scales horizontally.
Each customer AWS account gets:
- Its own cross-account role.
- Its own unique external ID.
So you end up with something like:
- Customer A
- Account ID:
111122223333 - Role:
arn:aws:iam::111122223333:role/ThirdPartyReadOnly - External ID:
cust-111122223333-prod
- Account ID:
- Customer B
- Account ID:
777788889999 - Role:
arn:aws:iam::777788889999:role/ThirdPartyReadOnly - External ID:
cust-777788889999-prod
- Account ID:
On the vendor side, they store a mapping of “customer → role ARN + external ID” and always pass the right pair into AssumeRole.
The important part for you, as a customer, is to insist on:
- One role per AWS account.
- One external ID per AWS account.
Hardening the pattern: sessions, logging, and guardrails
Once you’ve got the basic cross-account role in place, there are a few things you can do to make it production-worthy.
First, keep session duration reasonable. Most vendors don’t need 12-hour sessions. Set MaxSessionDuration on the role to something sensible (for example, one hour) and confirm the vendor can work with that.
Second, make sure you have visibility. CloudTrail should be enabled across the accounts where these roles live. You want to be able to answer:
- When is this role being used?
- From where (regions, IPs)?
- What APIs are being called?
If you’ve got a SIEM or Security Hub in place, wire those events in and at least set up basic detections for odd behaviour: role assumed at strange times, in strange regions, or making calls outside its usual profile.
Third, if you’re using AWS Organizations, consider coarse guardrails with SCPs. Service Control Policies can enforce global limits above the role itself. For example, they can prevent any role in a given account from making certain classes of API calls, even if someone messed up the permissions policy.
You don’t need an over-engineered setup on day one, but you do want more than “we created a role and forgot about it”.
The mistakes that cause trouble
Over time, the same mistakes keep showing up:
- No external ID in the trust policy at all.
- External IDs that are shared across customers or easy to guess.
- Roles that quietly have
ReadOnlyAccessorAdministratorAccessattached because someone was in a hurry. - A single role reused across dev, test, and prod environments.
- Zero logging or alerting on third-party roles, so you only notice issues after the fact.
None of these are exotic. They’re just the result of trying to “get the vendor unblocked quickly” and never coming back.
A simple mental model
Whenever a third-party tool asks for access to your AWS account, default to this model:
- Role, not user. No long-lived keys, ever.
- External ID, not blind trust. Unique per customer account.
- Least privilege, not convenience. Start narrow, grow only when needed.
- Short sessions, not permanent access. Rotate naturally.
- Logged and monitored, not invisible. You should be able to see every assume-role and what follows.
Once you’ve implemented this pattern once or twice, it’s just another bit of infrastructure you stamp out for every vendor. The payoff is that you can say “yes” to third-party integrations without quietly burning your threat model each time.

