Stop storing customer cloud keys: cross-account IAM with external IDs, end to end
How to authenticate into customer AWS accounts without holding a single long-lived key: assume-role, External IDs, and the defense-in-depth layers around them.

If you build anything that touches a customer’s AWS account — a deployment tool, a cost scanner, a backup service — you will face the same question on day one: how do we authenticate into their account?
The lazy answer is “ask for an access key pair and store it encrypted.” We refused to do that, and after building the alternative end to end, I want to walk through exactly how it works, including the parts that are easy to get wrong.
Why stored keys are a liability you can’t pay down #
A long-lived AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pair has three properties that make it the worst possible thing to hold for someone else:
- A leak is total compromise.Keys are bearer credentials. Anyone who exfiltrates your database (or your backups, or your logs) holds the customer’s account, from anywhere on the internet, until someone notices.
- There is no good revocation story. Rotation requires the customer to mint new keys and re-paste them into your UI. In practice nobody rotates, so the keys you stored in 2024 still work in 2027.
- Encryption at rest doesn’t save you.The application has to decrypt the keys to use them, so the decryption path exists, and your blast radius is “everything the app can read.”
The alternative has been sitting in AWS since 2011: cross-account role assumption with an External ID. The customer creates an IAM role in their own account that trusts yourplatform’s IAM principal. You never hold a secret that grants standing access — you hold the right to ask STS for temporary credentials, and the customer can take that right away at any moment.
The trust policy, exactly #
The customer-side role carries a trust policy shaped like this:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<PLATFORM_ACCOUNT>:role/PlatformControlPlane" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "<per-customer-random-value>" }
}
}]
}Two things matter here. First, the Principalis your platform’s single control-plane role — not an account root, not a wildcard. Second, the sts:ExternalIdcondition. People consistently misunderstand what the External ID is for, so let’s be precise.
The External ID is not a credential.It’s a per-customer-account random value (we generate 32 bytes from a CSPRNG and base64url-encode it) that defends against the confused deputy problem. The attack it prevents: an attacker signs up for your service, then convinces your platform to assume a role in a victim’saccount — possible because the victim’s trust policy names your platform principal, and your platform is the deputy doing the assuming. If your platform always passes the External ID it generated for the account the requesting user registered, the attacker can’t point your platform at someone else’s role: the victim’s trust policy demands the External ID generated for the victim’s connection, which the attacker never sees and cannot set.
Because it’s a shared identifier rather than a secret credential, 32 bytes of randomness is plenty, and the threat model survives it appearing in a CloudFormation parameter. We still treat it with hygiene — return it once at connection creation for the copy-paste snippet, expose it afterwards only via a dedicated trust-policy endpoint, never in list views.
The runtime path: assume, scope, expire #
At deploy time the control plane authenticates as itself (env credentials in dev, IRSA or instance metadata in prod), then makes one STS call:
stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
RoleArn: aws.String(customerRoleARN),
RoleSessionName: aws.String("platform-1717939200"),
ExternalId: aws.String(externalID),
DurationSeconds: aws.Int32(3600),
})The result is a triplet — access key, secret, session token — valid for one hour, scoped to whatever the customer-side role’s permission policy allows, and stamped with a session name that shows up in the customer’s CloudTrail. Nothing persists. If our database leaks tomorrow, the attacker gets role ARNs and External IDs, neither of which is usable without also being our platform principal.
To make onboarding survivable we generate a CloudFormation quick-launch URL with the principal ARN and External ID pre-filled as stack parameters, so “create the role” is one click and a review screen, and the customer can read every line of what they’re granting before they do.
Defense in depth: the boring layers that matter #
Role assumption is the headline, but two unglamorous layers do a lot of work.
The Terraform state bucket only accepts the platform identity. State files contain resource IDs, endpoints, and occasionally sensitive values, and they live in ouraccount, not the customer’s. The bucket uses SSE-S3 by default (customer-managed KMS is an optional override, not a mandate) — but encryption isn’t the interesting control. The interesting control is a bucket policy with an aws:PrincipalAccount deny: any principal outside the platform account is refused, full stop. Even a leaked pre-signed URL or a misconfigured cross-account grant dies at the bucket policy.
The Terraform runner strips identity-bearing env vars before every run.This one came from staring at the failure mode rather than hitting it. Terraform’s AWS provider happily reads AWS_ACCESS_KEY_ID from the process environment — and the S3 backend reads the same environment. If a customer-supplied key ever landed in the runner env, it could silently become the identity used to write our state bucket, or worse, the backend’s platform identity could leak into the customer-account provider. So the runner builds its subprocess environment from a short host allowlist (PATH, proxy vars, IRSA token paths), explicitly deletes AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_PROFILE, AWS_DEFAULT_PROFILE, and AWS_SECURITY_TOKENfrom anything caller-supplied, and routes customer-account access exclusively through the provider’s assume_role block. Two identities, two channels, no ambient overlap.
Drift detection on the role’s permissions.Cross-account roles rot too — customers trim a policy and deploys start failing with opaque AccessDenieds weeks later. We run iam:SimulatePrincipalPolicy against the full required-action list on demand and surface the exact missing actions, instead of letting someone discover them one failed apply at a time.
What the customer keeps #
This is the part that makes the pattern worth the onboarding friction. The customer can:
- Read everythingbefore granting it — the role’s permission policy is a reviewable document, not a black box.
- Scope it down— it’s their IAM role; they can attach a tighter policy and let simulation tell them what broke.
- Audit every action— every assumed-role session lands in their CloudTrail with a recognizable session name.
- Revoke instantly— delete the role or edit one line of the trust policy, and the platform’s access ends mid-flight. No support ticket, no “please confirm you’ve rotated.”
That last property is the whole argument. With stored keys, the customer’s security depends on yoursecurity forever. With assume-role and an External ID, it depends on a trust policy they own and can tear up unilaterally. If you’re building anything multi-tenant against AWS, build this from day one — retrofitting it after you’ve stored keys means migrating every customer.
I work on Vylara, a deployment platform where this pattern is the only supported way to connect an AWS account — we never accept static keys.
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 free


