Module 2: Federated Identity And Least Privilege Iam

4. Hands-on: creating the IAM OIDC Identity Provider

Description

This is the lesson where lesson 3's "who I trust" stops being a diagram and becomes real HCL, validated by Terraform's real engine. You're going to create modules/oidc-provider/ — this guide's first genuinely new directory — with a single resource: aws_iam_openid_connect_provider. terraform fmt, init, validate, and plan run for real, against this environment's Terraform 1.15.8 and the hashicorp/aws ~> 6.0 provider — without needing, yet, any LocalStack running.

Connection to the module

Lesson 3 gave you the exact vocabulary: the identity provider is the record, inside IAM, that this account trusts tokens signed by token.actions.githubusercontent.com. This lesson declares that record. Lesson 5 extends this same module with the second piece — the role and its trust policy — so modules/oidc-provider/'s design, starting with this lesson, already anticipates that extension: nothing you write here needs rewriting later, it only grows.


Step 1 — The module's structure, before the content

  andes-cargo-infra/
  └── modules/
      └── oidc-provider/
          ├── main.tf         ← the resource
          ├── variables.tf    ← its inputs
          └── outputs.tf      ← what it exposes to the root module

The same three-file pattern you already know from modules/s3-bucket/ and modules/iam-role/ in terraform-and-iac-guide — this guide doesn't invent a new convention.


Step 2 — modules/oidc-provider/variables.tf

variable "thumbprint_list" {
  description = "SHA-1 thumbprints of the GitHub Actions OIDC issuer's TLS certificate chain."
  type        = list(string)
  default     = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

variable "tags" {
  description = "Tags applied to the OIDC provider and the role this module creates."
  type        = map(string)
  default     = {}
}

Two variables, both with a default — unlike modules/iam-role/, where role_name and the two policies were required because a role without them makes no operational sense. Here, thumbprint_list has a reasonable default value (the real, current thumbprint of token.actions.githubusercontent.com's certificate, the same one you already saw cited in cicd-and-gitops-on-aws-guide M4.5) because almost no project needs to change it — leaving it as a default saves every module call from repeating a hexadecimal value that rarely changes, without preventing anyone from overriding it if AWS or GitHub ever rotate that certificate.


Step 3 — modules/oidc-provider/main.tf

resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  thumbprint_list = var.thumbprint_list

  tags = var.tags
}

Four arguments, each with a concrete reason, already explained in lesson 3: url is the exact iss a GitHub Actions JWT declares; client_id_list is the audience this provider accepts — sts.amazonaws.com, the same value lesson 5's trust policy is going to compare against aud; thumbprint_list is the issuer's TLS certificate's cryptographic fingerprint, the piece that lets AWS confirm it's talking to GitHub's real server and not an impostor; tags, the same tags block from the whole project, inherited from local.common_tags.


Step 4 — modules/oidc-provider/outputs.tf

output "provider_arn" {
  description = "ARN of the GitHub Actions OIDC identity provider."
  value       = aws_iam_openid_connect_provider.github_actions.arn
}

A single output for now — the provider's ARN, the exact piece lesson 5's trust policy is going to need as Principal.Federated. Lesson 5 adds more outputs to this same file, without touching this one.


Step 5 — Calling the module from the root

In andes-cargo-infra/, create oidc.tf:

module "github_oidc" {
  source = "./modules/oidc-provider"

  tags = local.common_tags
}

With no required input yet — thumbprint_list uses its default, and lesson 5's role (which will require inputs) doesn't exist in the module yet. It's, on purpose, the simplest call possible: it confirms the module works before adding the piece that does need Andes Cargo-specific data.


Step 6 — fmt, init, validate: the real engine, with no LocalStack running

terraform fmt -recursive
terraform init

What to expect (literal, executed to write this lesson — Terraform 1.15.8, provider hashicorp/aws resolved to 6.60.0 inside the ~> 6.0 range):

Initializing the backend...

Initializing modules...
- github_oidc in modules/oidc-provider

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.60.0...
- Installed hashicorp/aws v6.60.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

Notice the second line: Initializing modules... - github_oidc in modules/oidc-provider — Terraform confirms, before resolving any provider, that it found and can read the module you just wrote. Same v6.60.0 you already saw resolved in terraform-and-iac-guide for this same ~> 6.0 range — the version pin keeps producing the expected result in this ecosystem.

terraform validate

What to expect (literal):

Success! The configuration is valid.

Step 7 — The plan, literal, executed to write this lesson

terraform plan

What to expect (literal, executed to write this lesson):

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
Terraform will perform the following actions:

  # module.github_oidc.aws_iam_openid_connect_provider.github_actions will be created
  + resource "aws_iam_openid_connect_provider" "github_actions" {
      + arn             = (known after apply)
      + client_id_list  = [
          + "sts.amazonaws.com",
        ]
      + id              = (known after apply)
      + tags            = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + tags_all        = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + thumbprint_list = [
          + "6938fd4d98bab03faadb97b34396831e3780aea1",
        ]
      + url             = "https://token.actions.githubusercontent.com"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

A single resource to create, exactly what you'd expect the first time you declare a module. arn and id are (known after apply) — AWS only assigns an identity provider's ARN at the moment it's created, not before — but url, client_id_list, and thumbprint_list are already resolved in the plan, because they don't depend on any network call: they're literals you wrote yourself.


Step 8 — Applying and verifying (representative)

What to expect (representative) — this writing environment doesn't have a LOCALSTACK_AUTH_TOKEN exported, so the LocalStack container doesn't start here (Could not connect to the endpoint URL), the same exact limit you already saw in Module 1, lesson 4. What follows is reconstructed field by field from the real plan above:

tflocal apply -auto-approve
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creating...
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creation complete after 1s [id=arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
awslocal iam list-open-id-connect-providers

What to expect (representative):

{
    "OpenIDConnectProviderList": [
        {
            "Arn": "arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"
        }
    ]
}

One identity provider, with an ARN following the pattern arn:aws:iam::<account>:oidc-provider/<url-without-scheme> — notice the ARN doesn't include https://, only the domain, even though the url you declared in the HCL did carry it. It's a real detail of this resource's format, not a transcription error.

To see the full detail, not just the ARN:

awslocal iam get-open-id-connect-provider \
  --open-id-connect-provider-arn arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com

What to expect (representative — CreateDate varies, everything else is fixed for this exact HCL):

{
    "Url": "token.actions.githubusercontent.com",
    "ClientIDList": ["sts.amazonaws.com"],
    "ThumbprintList": ["6938fd4d98bab03faadb97b34396831e3780aea1"],
    "CreateDate": "2026-08-13T10:12:04+00:00",
    "Tags": [
        {"Key": "Project", "Value": "andes-cargo"},
        {"Key": "Environment", "Value": "dev"},
        {"Key": "ManagedBy", "Value": "terraform"}
    ]
}

Again, Url with no https:// scheme — this is the documented behavior of the IAM API for this specific resource, confirmed against the official documentation: the response's Url field never includes the protocol, even though the creation argument requires it.


Common mistakes

Writing url without the https:// scheme in the HCL, "because the API response doesn't have it either" (direction-confusion mistake). What happens: someone, having already seen get-open-id-connect-provider's response without https://, writes url = "token.actions.githubusercontent.com" in the resource, without the scheme. How to spot it: terraform plan fails with a provider validation error (url must be a complete URL with a scheme) before even attempting anything against AWS. How to fix it: the creation argument (url in the HCL) requires the full scheme (https://token.actions.githubusercontent.com); it's the API response, after creation, that omits it. They're two different formats of the same data, in two different directions of the same resource.

Declaring a second aws_iam_openid_connect_provider for the same issuer, per project or per role (design mistake). What happens: someone, later on, needs a second role for a second repository, and declares a whole second identity provider instead of reusing the one that already exists. How to spot it: if your plan tries to create an aws_iam_openid_connect_provider with the same url as one that already exists, AWS rejects the creation — EntityAlreadyExists — because an identity provider for a given issuer is unique per account, not per role or per project. How to fix it: an identity provider is declared once per account and per issuer; multiple roles, for multiple repositories, reference the same identity provider in their Principal.Federated — exactly the pattern lesson 5 builds on this same module.

Forgetting terraform init after creating the module for the first time. What happens: someone writes the three files of modules/oidc-provider/ and runs terraform plan directly, without init first. How to spot it: the Module not installed error — Terraform needs to register the new module in its dependency tree before it can plan against it, exactly the same behavior you already saw with modules/iam-role/ and modules/s3-bucket/ in terraform-and-iac-guide. How to fix it: any new module, or any change to an existing one's source path, requires a terraform init before the next plan.


Exercises

Exercise 1 — Explain why arn is (known after apply) but url isn't. Without looking back at this lesson's plan, explain in two or three sentences why Terraform can show url's exact value in the plan, but not arn's.

See solution

url is a value you wrote directly in the HCL — a literal, known before any communication with AWS even exists — so Terraform can show it as-is in the plan, with no network call needed. arn, on the other hand, is an attribute AWS assigns at the moment the resource is created — it includes the account and follows a format that depends on the API's actual response — so Terraform has no way to know it until after the apply. It's the same distinction you already saw with RoleId/CreateDate in terraform-and-iac-guide: any value generated by AWS, never by you, shows up as (known after apply).

Exercise 2 — Predict what would happen if client_id_list were empty. If, by mistake, someone declared client_id_list = [] instead of ["sts.amazonaws.com"], what would happen, conceptually, to any later attempt to assume a role via this identity provider?

See solution

It would fail, no matter how well-configured the role's trust policy was — client_id_list defines which audiences (aud) the identity provider accepts at all; an empty client_id_list means no token, regardless of its aud, would be accepted by this provider. It's a check that happens at the identity provider itself, before the role's trust policy even evaluates its own condition on aud — two different verification layers, the first on the provider's side, the second on the role's side, both necessary.

Exercise 3 — Verify from memory modules/oidc-provider/'s complete structure as this lesson closes. Without looking back, describe this module's three files and what each contains at this exact point in the guide.

See solution

main.tf — a single resource, aws_iam_openid_connect_provider.github_actions, with url, client_id_list, thumbprint_list, and tags. variables.tf — two variables, thumbprint_list and tags, both with a default. outputs.tf — a single output, provider_arn. If you remembered all three files and their exact content, you have a clear picture of the foundation lesson 5 is going to build on, without rewriting anything that already exists here.


Summary and next step

In this lesson you built modules/oidc-provider/, this guide's first genuinely new directory, with a single resource declared: aws_iam_openid_connect_provider. You ran real fmt/init/validate/plan, against Terraform 1.15.8 and the hashicorp/aws provider resolved to 6.60.0 — with no LocalStack running, exactly like any other plan in this ecosystem. You confirmed (representative) that the apply produces the expected identity provider, with an ARN that omits the URL's scheme, a real detail of this specific resource's format.

Before moving on you should be able to: write this module's complete main.tf from memory; explain why client_id_list and thumbprint_list play different roles, even though both are lists of strings; and explain why an identity provider is declared once per account, not once per role.

Lesson 5 extends this same module with the flow's second piece: the role a GitHub Actions pipeline can assume, with a trust policy conditioned, with repository and branch precision, on the JWT's sub claim.

Resources

  1. AWS Docs — Creating OpenID Connect (OIDC) identity providers — complete official documentation for the resource declared in this lesson.
  2. Terraform Registry — aws_iam_openid_connect_provider — complete reference for this lesson's HCL resource.
  3. AWS CLI — iam list-open-id-connect-providers — complete reference for Step 8's verification command.
  4. AWS CLI — iam get-open-id-connect-provider — complete reference for the command that shows the provider's detail, including the schemeless Url format.
  5. cicd-and-gitops-on-aws-guide, Module 4, lesson 5 — the original source of the thumbprint_list used as default in this lesson.