Hedronite · Ops Lesson · 01-Earth-DevOps / Terraform · Thu 2026-08-27

Terraform AWS Assume Role — the role that is not a key

A static key is a passport. A role is a visa. The plugin must ask STS for the visa.

Lesson Class: Ops (DevOps + Terraform + AWS IAM / STS)
Cloud Referent: AWS IAM / STS — provider assume_role + aws_caller_identity. Rebalances after GCP Cloud SQL 08-24 and Azure Storage 08-21.
Paired Dev: Python boto3 STS against a Terraform-exported role ARN
Paired Cert: Pro-depth Lab 08: IAM trust / instance profile
Grounding: Brikman Ch.7 Working with Multiple AWS Accounts pp.376-379 · sts.md · Lab 08 referenced
The nest
assume_role is legal because the aws plugin published it. access_key is a passport in the file.
The handshake
Trust answers who. Permission answers what. Both halves must agree.
The proof
aws_caller_identity is a live STS lookup. Two aliases that print one account have not assumed.
A static key is a passport. A role is a visa. The plugin must ask STS for the visa.

<!-- hal:authoritative:yaml -->

A static key is a passport. A role is a visa. The plugin must ask STS for the visa.

§I — Frame

Monday this arc asked the google plugin to answer a settings nest Cloud SQL never invented. Three days before that, terraform output kept Tuesday's Azure URL. Three days before that, an S3 backend wrote without the DynamoDB claim. Today's overlay is AWS on purpose. The last three TF Ops visits sat on GCP, Azure, AWS-as-lock. IAM is the rebalance, and it is the Security row the Bootcamp AWS core-services cheatsheet actually names: users, roles, policies, MFA.

You open a provider "aws" block. You type access_key and secret_key because the first tutorial did, because a laptop already has those two strings in ~/.aws/credentials, because the plan will run if you paste them. The plan does run. The apply does write. The key lives in process memory, in the debug log if you were sloppy, and in the laptop after the run ends. Nothing expired. Nothing was assumed. You authenticated as a person-shaped credential.

Coin it: the role that is not a key.

This is not 08-03. That lesson isolated sts:AssumeRoleWithWebIdentity at GitHub Actions. The trust principal was an OIDC provider. The condition named a repo and a branch. Brikman, GitHub Actions as a CI server with OIDC, spent those pages. Today the action is sts:AssumeRole. The principal is an IAM user, another role, or the default credential chain the plugin already holds. Two verbs. Two trust documents. One plugin.

This is not 08-24. That lesson isolated a nest the google plugin published. assume_role is also a nest. The isolation that matters is not the nest grammar. The isolation is what the nest replaces: a pair of long-lived strings that never expire.

This is not 08-18. The S3 backend can still sit under this root. The lock table can still exist. Today's question is who the plugin is when it talks to IAM, not who holds the lock.

Brikman, Working with Multiple AWS Accounts, is blunt. In the console you click Switch Role. In Terraform you add an assume_role block to the child provider and you put a role ARN in role_arn. The plugin does not become that account because you typed the account id in a comment. The plugin becomes that account because STS returned a session.

§II — Foundations: four facts about the visa

Fact one. The provider block is a process, and that process must already be someone.

provider "aws" { region = "us-east-2" } does not invent an identity. The plugin walks the default chain in a fixed order: environment variables, a shared config profile, then a container or instance role if one exists. Brikman writes the parent account that way. A profile = "parent" attribute selects a named profile. It still is not a key in the file. It is a pointer at a chain the laptop or the runner already holds. No access_key. No secret_key. The chain is the starting principal. If the chain is empty, terraform plan fails at the first AWS call with a credentials error. That failure is honest. A block that pastes two strings to silence it has traded honesty for a key.

The Bootcamp IAM notes are equally blunt: never write IAM credentials in your code. The provider file is code. access_key and secret_key attributes are credentials in code. A variable "aws_secret_key" that you pass on the command line is still a key. It is a key with a prettier door.

**Fact two. assume_role is a nest the aws plugin published, and STS is who answers it.**

provider "aws" {
  region = "us-east-2"
  alias  = "child"

  assume_role {
    role_arn     = "arn:aws:iam::222222222222:role/OrganizationAccountAccessRole"
    session_name = "tf-child"
  }
}

Brikman uses OrganizationAccountAccessRole because Organizations writes that role for you and points its trust policy at the parent account. The nest is still a nest. role_arn is required for the nest to mean anything. session_name is how CloudTrail will label the assumption. external_id exists for the confused-deputy case: a third party that assumes roles in many customer accounts and must not let customer A point the vendor at customer B. duration_seconds is a wish the role's max-session-duration can refuse.

The Bootcamp STS notes list what comes back: AccessKeyId, SecretAccessKey, SessionToken, Expiration. Those four values are a session. They look like a key. They expire. They do not belong to the identity that asked. They belong to the role, for a bounded time, under the permission policy attached to that role. The starting principal gave up its own permission for the length of the session. That swap is the IAM notes' first contrast with a resource-based policy, where the principal keeps what it had.

Fact three. Cross-account is double opt-in.

Brikman's first warning after the worked example: an IAM role that allows access from another account needs grants in both accounts. In the child account, the role's trust policy must name the parent as a principal that may sts:AssumeRole. In the parent account, the starting principal must be allowed to call sts:AssumeRole on that role ARN. Organizations did the first half for OrganizationAccountAccessRole. A custom role you author yourself does neither half by magic.

If/then: if the trust policy names the parent and the parent has no sts:AssumeRole on the ARN, the plugin is a well-dressed stranger. If the parent can call AssumeRole and the trust policy names a different account, STS refuses. If both halves agree, you get a session. The exam and the outage both live in the missing half.

A trust policy that names AWS = "*" is a visa printer. A permission policy that names s3:* on * is a passport with every page stamped. The two documents are not interchangeable. Trust answers who. Permission answers what. Lab 08 drills that split on an EC2 service principal. Today's shirt is an account principal. The split does not change.

**Fact four. Proof is aws_caller_identity, not a comment.**

Brikman adds two data sources and two outputs:

data "aws_caller_identity" "parent" {
  provider = aws.parent
}

data "aws_caller_identity" "child" {
  provider = aws.child
}

output "parent_account_id" {
  value       = data.aws_caller_identity.parent.account_id
  description = "The ID of the parent AWS account"
}

output "child_account_id" {
  value       = data.aws_caller_identity.child.account_id
  description = "The ID of the child AWS account"
}

A successful apply that prints 111111111111 and 222222222222 is the only proof the child provider assumed. Two aliases that both print the parent id mean the assume_role nest never fired, or fired and landed back home. A plan that never reads caller identity can still create resources in the wrong account. 08-21 already taught you that an output can be last-apply. account_id is not last-apply in that sense. It is a live STS lookup through that plugin process. If the child process is still the parent, the number will say so.

§III — Mechanism: the plugin asks, then STS answers

Terraform Core never knew what an IAM role was. 07-28 said this about providers in general. Today we watch one nest travel the path.

  1. terraform init reads required_providers. The source hashicorp/aws is a registry address. The lock file pins the binary. Two aliases of hashicorp/aws are still one plugin type and two configured processes. Brikman, Working with Multiple Different Providers, is the leftover heading 08-24 already spent on google versus google-beta. Today both processes are hashicorp/aws. The difference is the nest, not the source address.
  1. terraform validate loads the plugin, asks for schema, and walks the assume_role nest. An unknown attribute inside the nest dies here. A missing role_arn dies here if the schema marks it required. No STS call happens. Validate answers as a document.
  1. terraform plan asks the plugin to read. The parent process uses the default chain. The child process uses the default chain, then calls sts:AssumeRole with the ARN you typed. STS evaluates the trust policy, evaluates the caller's right to assume, and returns a session or a denial. Every subsequent read in that process signs with the session. aws_caller_identity on the child is the first cheap proof.
  1. terraform apply asks the plugin to write. Writes in the child process land in the child account. Writes in the parent process stay in the parent. A resource that forgot provider = aws.child inherits the default process, which is usually the parent. That is the 07-28 implicit-provider failure wearing an IAM shirt. Two accounts. One omitted argument. One surprise bill.

Role chaining is the professional trap. If the starting principal is already an assumed role, and that session assumes again, STS caps the second session at one hour even when the target role allows more. The Bootcamp STS notes add the revoke shape: you cannot cancel a session. You can attach AWSRevokeOlderSessions so older sessions die on their next call. Changing the permission policy mid-flight punishes the legitimate holders too. The visa expires. You do not shred it from across the room.

OIDC stays out of the mechanism. AssumeRoleWithWebIdentity is a different API. The trust principal is an identity provider, not an account. 08-03 owns that shirt. If this lesson starts writing token.actions.githubusercontent.com:sub, it has become 08-03 again.

§IV — Worked Example: two processes, one visa, one illegal line

A root module that must read the parent account and write a log bucket in the child, without a static key in either block.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  alias  = "parent"
  region = "us-east-2"
}

provider "aws" {
  alias  = "child"
  region = "us-east-2"

  assume_role {
    role_arn     = var.child_role_arn
    session_name = "tf-multi-account"
    external_id  = var.child_external_id
  }
}

data "aws_caller_identity" "parent" {
  provider = aws.parent
}

data "aws_caller_identity" "child" {
  provider = aws.child
}

resource "aws_s3_bucket" "audit" {
  provider = aws.child
  bucket   = var.audit_bucket_name
}

output "parent_account_id" {
  value = data.aws_caller_identity.parent.account_id
}

output "child_account_id" {
  value = data.aws_caller_identity.child.account_id
}

output "audit_bucket" {
  value = aws_s3_bucket.audit.bucket
}

The illegal line is the one you do not write:

provider "aws" {
  alias       = "child"
  region      = "us-east-2"
  access_key  = var.child_access_key
  secret_key  = var.child_secret_key
}

That block authenticates as a key. It will plan. It will apply. It will still be a key tomorrow morning. The Bootcamp IAM notes call that a credential in code. Brikman does not offer it as the multi-account pattern. The multi-account pattern is the nest.

var.child_role_arn is an ARN, not a secret. It is safe to store in tfvars. var.child_external_id is a shared secret for the confused-deputy case and should not be printed. It is still not an access key. It is a condition on the trust policy. STS checks it. Terraform does not mint it.

A default provider with no alias still exists if you write provider "aws" { region = "us-east-2" } without alias. Resources that omit provider = bind to it. The worked example names both processes so a forgotten argument fails validate or binds to nothing useful. Prefer the named pair. Do not keep a third anonymous process "for convenience."

The child account must already hold the role. Terraform can create that role in a separate root that runs as the child, with a trust policy that names the parent. Do not create the role and assume it in the same apply through the same process. The visa cannot be issued by the passport it is trying to become. Two applies, two states, or a targeted apply after the role exists. Lab 08 will author the trust document. This Ops lesson consumes the ARN.

§V — Connection to Prior Lessons

08-24 taught a nest the google plugin had to answer. assume_role is a nest the aws plugin has to answer. The grammar is the same altitude. The meaning is not. Cloud SQL settings.database_flags configures an instance. assume_role.role_arn configures who the plugin is. Do not rebuild the Cloud SQL shirt.

08-21 taught that terraform output can still be last-apply. aws_caller_identity.account_id is a live read. Use it as proof, not as a pretty string you typed into outputs.tf.

08-18 taught the S3 backend and the DynamoDB LockID. That cluster can sit under today's root. The backend identity is a different question: the process that talks to S3+DynamoDB is often the parent chain, not the assumed child. Do not confuse the backend visa with the provider visa. Two processes can share a laptop and hold different claims.

08-03 taught OIDC at GitHub Actions. Keep that lesson. Do not spend this fire rewriting id-token: write and configure-aws-credentials. The verb today is AssumeRole, not AssumeRoleWithWebIdentity.

07-28 taught two aliases of one plugin at the root. Today's aliases are two accounts, not two regions. The providers map into a child module is 08-24 / Lab 14. This fire does not reopen that map.

§VI — Connection to Today's Dev Lesson

The Dev slot is Python-around-TF. tf_day_dev_counter reads 10, 10 mod 3 is 1. The script will call terraform output -json, take the role ARN that this root exported, and ask boto3 sts.assume_role for its own session. It will refuse to fall back to a static key in the environment when the assume fails.

This Ops lesson is the nest that produces the ARN. If the Dev lesson only wraps terraform apply and watches the lock, it has repeated 08-18. If this Ops lesson only restates IAM vocabulary, it has skipped the plugin. The hinge is one sentence. Terraform asks STS through the provider nest. Python asks STS through boto3. Both consume a role. Neither should mint a key.

§VII — Closing

access_key and secret_key in a provider block are a passport that does not expire. assume_role.role_arn is a visa the plugin must request. Trust answers who. Permission answers what. Both halves must agree. aws_caller_identity is the stamp that proves which account answered.

Name it when you see it. The role that is not a key. Write the nest. Leave the strings out of the file. Ask STS. Print the account ids. Then write the bucket.

Examine well. The alias will still be pretty. The door is role_arn. The proof is the caller identity. The plugin is the one who asks first.

Related