AWS IAM cross-account access setup: a practical tutorial
Set up AWS IAM cross-account access with an IAM role, a trust policy, and STS AssumeRole. The exact steps, the pitfalls, and how to verify it works.

Cross-account IAM access in AWS comes down to one pattern: the account that owns the resources creates an IAM role with a trust policy naming the other account (or a principal in it), and the calling side uses `sts:AssumeRole` to get temporary credentials. You never copy long-lived access keys between accounts. This tutorial walks the full setup — creating the role, writing the trust and permission policies, and verifying the handshake — and calls out the two mistakes that cause most of the AccessDenied errors people hit. If you want the broader context first, the AWS section collects related guides.
Why roles instead of shared keys #
The naive approach — generating an IAM user with access keys in Account B and pasting them into Account A — works until it doesn’t. Static keys don’t rotate, they show up in logs and environment files, and revoking them means hunting down every copy. A cross-account role fixes all of that. The trust relationship lives in one place, the credentials the caller receives are temporary (15 minutes to 12 hours, default 1 hour), and revocation is a single edit to the trust policy. If you’ve read about the external-ID pattern for third-party access, this is the same machinery viewed from the setup side rather than the security-nuance side.
Two accounts are involved, and it helps to name them precisely. The trusting account (call it Account B, 222222222222) owns the resources and hosts the role. The trusted account (Account A, 111111111111) contains the principal — a user, role, or the account root — that will assume it. The direction matters: the role lives where the resources are, and it points back at who is allowed in.
Step 1: create the role and its trust policy in Account B #
In the account that owns the resources, create a new IAM role and attach a trust policy. The trust policy is the document that says who may assume this role — it is separate from the permissions policy, which says what they can do once inside. Here is a minimal trust policy that lets a specific role in Account A assume it:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:role/deployer" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "a-shared-secret-string" }
}
}]
}The ExternalId condition is optional for access between accounts you own, but you should add it whenever the trusted account belongs to someone else. It defeats the confused-deputy problem, where a third party is tricked into assuming a role on the wrong customer’s behalf. If you point Principal.AWS at the account root (arn:aws:iam::111111111111:root) instead of a specific role, you delegate the decision of which principals can assume it to Account A’s own IAM policies — convenient, but broader. Prefer naming the exact principal when you can, because the account-root form is only as safe as that account’s internal IAM hygiene.
Step 2: attach a permissions policy scoped to the job #
The role is useless until you attach a permissions policy describing what the assumed session may do. Resist the urge to attach AdministratorAccess. Scope it to the actual API calls the caller makes. A deploy role that manages compute and a database, for instance, needs container, load-balancer, and RDS actions — not iam:* on everything. This is the same discipline we apply when we generate tight security groups from your code: the narrowest ruleset that still works is the one you want, because the blast radius of a leaked session is exactly the size of the policy behind it. Write the policy, deploy once, then tighten it further if the logs show actions you never actually use.
Step 3: grant the caller permission to assume the role #
This is the step people forget, and it produces a confusing AccessDenied even when the trust policy looks perfect. Both sides must agree. The trust policy in Account B allows the assume; the calling principal in Account A also needs an identity policy that permits sts:AssumeRole on the target role ARN. If either side is missing, the call fails. Attach this to the deployer role in Account A:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::222222222222:role/cross-account-deploy"
}]
}Step 4: assume the role and verify #
With both policies in place, exchange your identity for a temporary session. From the CLI, aws sts assume-role --role-arn arn:aws:iam::222222222222:role/cross-account-deploy --role-session-name test --external-id a-shared-secret-string returns an access key, secret key, and session token valid for the session duration. Export those three and call aws sts get-caller-identity — the returned ARN should show the assumed-role session in Account B, not your original identity. That single verification call is the fastest way to confirm the whole chain works before you wire it into anything real. If it still fails, work through the two policies in order: caller-side sts:AssumeRole first, then the trust policy’s principal and ExternalId.
How Vylara uses this so you don’t manage keys #
Vylara provisions and manages infrastructure in your own AWS account, which means it needs exactly this pattern — and it uses the role-and-AssumeRole handshake rather than asking you to hand over static keys. You connect your cloud account once; Vylara validates the stored credentials with an sts:GetCallerIdentity call before it ever touches your resources, and can assume a role with an external ID when one is configured. From there the analyze-and-deploy flow opens a pull request with your Dockerfile and CI, and the first deploy creates the cloud environment in your account. Because everything lives under a role you own, revoking Vylara’s access is a one-line trust-policy edit on your side — the same escape hatch this whole pattern is built to give you.
Honest limits worth stating: AssumeRole sessions are capped at 1 hour by default and 12 hours maximum, so long-running jobs must re-assume as sessions expire. Chained role assumption (role A assumes role B, which assumes role C) is hard-capped at 1 hour regardless of the role’s configured maximum. And a trust policy pointed at an account root is only as safe as that account’s internal IAM hygiene — name specific principals when you can. None of this is exotic; it is the same handshake every well-behaved cross-account integration relies on, and getting the two-sided permission model right is most of the work.
Review your cloud plan in Vylara, merge delivery changes as Git PRs, and deploy into your own AWS or Azure account when you’re ready.
Start freeFrequently asked questions
- Why do I get AccessDenied when the trust policy looks correct?
- Cross-account access requires permission on both sides. The trust policy on the target role must allow the caller, and the calling principal must also have an identity policy granting sts:AssumeRole on that role's ARN. Missing the caller-side permission is the most common cause of AccessDenied even when the trust policy is perfect.
- Do I need an ExternalId for cross-account access?
- Not for accounts you own yourself, but you should require one whenever the trusted account belongs to a third party. The ExternalId condition prevents the confused-deputy problem, where a vendor could be tricked into assuming a role against the wrong customer's account.
- How long do assumed-role credentials last?
- By default an sts:AssumeRole session lasts 1 hour; you can raise the role's maximum to 12 hours. Role chaining (assuming a role from within an already-assumed session) is always capped at 1 hour regardless of the configured maximum, so long-running work must re-assume as sessions expire.



