Module 2: Federated Identity And Least Privilege Iam

3. How OIDC federation works, step by step

Description

If you came from cicd-and-gitops-on-aws-guide, you already know OIDC's complete mechanism: the JWT, the identity provider, the trust policy, temporary credentials. This lesson doesn't re-teach it from scratch — it reviews it with surgical precision on the two exact pieces you're going to write in HCL over the next two lessons: which exact JWT claim each trust policy condition reads, and which AWS resource represents each half of the flow. If this is your first guide in the ecosystem, this lesson builds the complete mechanism, with the same rigor.

Connection to the module

Lesson 2 established the problem with real evidence: a long-lived secret, once leaked, is permanent. This lesson presents the complete solution, on the AWS side, before lesson 4 declares the first Terraform resource. Everything that follows in this module — the identity provider (4), the trust policy (5), M2.6's experiment — rests on the exact vocabulary you fix here.


Analogy: the visitor pass a guard can verify without calling anyone

Go back to the office building guard. With a physical credential — a card, a key — the guard can only confirm one thing: that whoever presents it has the physical object. They don't know if that card was stolen, borrowed, or copied. Now imagine a different system: the visitor presents a document signed by an authority the building already decided, in advance, to trust — say, the head office of the company that invited them — with a signature the guard can verify on the spot, without calling anyone, and with an expiration date printed right on the document itself. The guard doesn't need to know the person. They need two things, and only two: knowing which authority they trust (to verify the signature), and having a list of what each type of document signed by that authority is allowed to do (which floors it can enter, with exactly which document).

Those two things — "who I trust" and "what I allow them to do" — are, literally, the two AWS resources you're going to build in lessons 4 and 5: the identity provider (who the account trusts) and a role's trust policy (what a token signed by that specific issuer is allowed to do).


The JWT, with its three exact claims

A JWT (JSON Web Token) has three parts, separated by dots: header.payload.signature. The first two are Base64URL-encoded JSON — anyone can decode and read them, they're not encrypted, only encoded; the third is the cryptographic signature that proves the payload wasn't altered since the issuer signed it. You'll build a real one, with Python, in lesson 6 — for now, three claims from the payload are what matter for everything that follows in this module:

ClaimWhat it meansReal value, for a push to main of andes-cargo-infra
iss (issuer)Who signed this tokenhttps://token.actions.githubusercontent.com
aud (audience)Who this token is forsts.amazonaws.com
sub (subject)Who, specifically, is the subject of this tokenrepo:andes-cargo/andes-cargo-infra:ref:refs/heads/main

iss answers "which authority does this document come from?" — it's what lesson 4's identity provider declares AWS recognizes. aud answers "who was this document issued for?" — GitHub signs JWTs for many different audiences (not just AWS); sts.amazonaws.com is, specifically, the value that tells this token "you're valid to authenticate against AWS STS, not any other service." sub answers "who, specifically, is the bearer of this document?" — and it's, with repository and branch precision, the piece lesson 5's trust policy is going to condition.


The complete flow, with AWS resources overlaid

   OIDC END TO END — GITHUB ACTIONS → AWS STS → TEMPORARY CREDENTIALS
   (with the AWS resources this module builds, overlaid)

   ┌──────────────────────────────┐
   │  1. GitHub Actions job        │   permissions: id-token: write
   │     requests a JWT             │   (you already saw this in cicd-and-gitops-on-aws-guide)
   └──────────────┬────────────────┘
                  │
                  ▼
   ┌──────────────────────────────┐
   │  2. GitHub signs the JWT       │   iss:  token.actions.githubusercontent.com
   │     (identity provider)        │   aud:  sts.amazonaws.com
   │                                 │   sub:  repo:andes-cargo/andes-cargo-infra:
   │                                 │        ref:refs/heads/main
   └──────────────┬────────────────┘
                  │
                  ▼                  ═══════════════ AWS SIDE ═══════════════
   ┌──────────────────────────────┐  ┌─────────────────────────────────────┐
   │  3. The job presents the JWT   │  │  aws_iam_openid_connect_provider     │
   │     to AWS STS, requests to    │─▶│  (Lesson 4 — "who I trust")          │
   │     assume AndesCargoDeployRole│  │  url = token.actions.githubuser...   │
   └──────────────────────────────┘  └──────────────┬──────────────────────┘
                                                       │
                                                       ▼
                                     ┌─────────────────────────────────────┐
                                     │  aws_iam_role.deploy                  │
                                     │  assume_role_policy (Lesson 5 —       │
                                     │  "what I allow them to do")           │
                                     │                                        │
                                     │  Principal: Federated = <ARN of       │
                                     │             the provider above>       │
                                     │  Condition:                           │
                                     │    aud == sts.amazonaws.com           │
                                     │    sub LIKE repo:andes-cargo/         │
                                     │        andes-cargo-infra:ref:         │
                                     │        refs/heads/main                │
                                     └──────────────┬──────────────────────┘
                                                     │ do aud and sub match?
                              ┌──────────────────────┴──────────────────────┐
                              │ YES                                         │ NO
                              ▼                                             ▼
                 ┌────────────────────────┐                    ┌────────────────────────┐
                 │  4. STS returns          │                    │  AccessDenied            │
                 │     TEMPORARY            │                    │  (the trust policy        │
                 │     credentials           │                    │   doesn't match — no       │
                 │     (minutes, not forever)│                    │   credential ever gets      │
                 └────────────────────────┘                    │   issued at all)            │
                                                                  └────────────────────────┘

Notice something cicd-and-gitops-on-aws-guide's lesson 4 already mentioned but that's worth underlining here, now that you see the two AWS resources overlaid: they're two distinct objects, with two distinct responsibilities, working together. The identity provider (top) knows nothing about repositories or branches — it only knows "I trust tokens signed by this issuer, with this certificate." The role's trust policy (bottom) knows nothing about cryptography — it only knows "of the tokens that already passed signature verification, I accept only ones with this exact sub." Neither is useful without the other: an identity provider with no role referencing it grants nobody access to anything; a role with a perfect trust policy but no identity provider configured has no real trust source to validate against.


The two pieces you're going to build, named precisely

aws_iam_openid_connect_provider (lesson 4) — the record, inside IAM, that this AWS account trusts tokens signed by token.actions.githubusercontent.com. It's declared once per account, no matter how many different roles end up using it — exactly the same one-to-many relationship of an identity provider to many roles you already saw with modules/iam-role/ in terraform-and-iac-guide, now applied to a different resource.

aws_iam_role with a conditioned assume_role_policy (lesson 5) — the specific role a GitHub Actions pipeline can assume, with a trust policy that doesn't say "I trust any token from GitHub" but, with repository and branch precision, "I trust only tokens whose sub is exactly repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main." This precision — not just any repository, not just any branch — is, verified against AWS's official documentation and already named in cicd-and-gitops-on-aws-guide M4.5, the most common OIDC misconfiguration in real implementations: an overly broad trust policy (for example, missing the branch part) would let any branch of that repository — including a feature branch opened by any collaborator — assume a role meant only for deployments from main.


Why this is, mathematically, less attack surface than a static key

It's worth quantifying the difference, not just describing it. A static AWS access key, once created, is valid indefinitely, until someone actively revokes it — its exposure window, if leaked, is "from the moment it leaked until someone notices and acts," which could be minutes or could be years. A temporary credential obtained via OIDC has, by design, a bounded exposure window from the moment it's created: it expires in minutes or hours, with nobody having to do anything. If an attacker managed to intercept a temporary credential in transit — a scenario already much harder than finding a key in a months-old commit — that credential becomes useless the moment it expires, with no human revocation action involved at all.


Common mistakes

Confusing aud with sub, and writing the wrong condition in the trust policy (preview of the mistake lesson 5 corrects in detail). What happens: someone, in a hurry, puts the repository pattern (repo:andes-cargo/...) in the aud condition, and sts.amazonaws.com in the sub condition. How to spot it: if your trust policy has token.actions.githubusercontent.com:aud compared against a repository pattern, or token.actions.githubusercontent.com:sub compared against sts.amazonaws.com. How to fix it: aud always compares against the fixed audience sts.amazonaws.com (the service that's going to consume the token) — it almost never changes between projects. sub always compares against the specific repository/branch pattern — it's the piece that does change for every role you declare. Mixing them up doesn't produce a syntax error (both are valid strings), it produces a trust policy that never matches any real token, with an AccessDenied and no obvious clue why.

Thinking the identity provider, by itself, already grants GitHub Actions access to something (sequencing mistake). What happens: someone declares aws_iam_openid_connect_provider (lesson 4) and expects that, alone, to enable a workflow to assume some role. How to spot it: if your mental model jumps straight from "I declared the identity provider" to "federation already works," with no role in between. How to fix it: the identity provider, alone, grants nobody permission to do anything — it only tells AWS "these tokens, if someone presents them, come from a source I recognize." You also need at least one role whose trust policy references that identity provider as Principal.Federated — exactly what lesson 5 builds.

Assuming "anyone can read a JWT" means "anyone can forge one" (conceptual mistake, revisited from cicd-and-gitops-on-aws-guide). What happens: someone, learning that a JWT's payload is just unencrypted Base64, concludes that anyone could write a JWT with whatever sub they wanted and pass as the real repository. How to spot it: if your security concern about OIDC is "but couldn't someone just make up a token with the right sub?" How to fix it: reading the payload (which is indeed public, by design — any system needs to be able to inspect the claims without decrypting anything) is completely different from signing a payload such that the JWT's third part (the signature) is valid — that requires the issuer's private key, which in a real GitHub Actions JWT only exists inside github.com's infrastructure. This module's lesson 6 builds a test JWT signed with your own symmetric key, precisely to demonstrate, live, the difference between "a token with the correct claims" and "a token a real AWS account would accept as legitimate."


Exercises

Exercise 1 — Trace the complete flow, marking which AWS resource corresponds to each step. Without looking at this lesson's diagram, draw the flow's four steps (request JWT, sign it, present it to STS, receive credentials) and note, at steps 3 and 4, which AWS resource (aws_iam_openid_connect_provider or aws_iam_role) is responsible for each check.

See solution

Steps 1 and 2 happen entirely on the GitHub side, with no AWS resource involved yet. Step 3 (the job presents the JWT to STS) is where AWS starts acting: first, aws_iam_openid_connect_provider verifies the issuer (iss) and signature are trustworthy; then, the specific aws_iam_role's assume_role_policy evaluates the conditions on aud and sub. Step 4 (temporary credentials) only happens if both checks — identity provider and trust policy — pass; if either fails, the flow ends in AccessDenied without ever issuing any credential.

Exercise 2 — Explain, using this lesson's vocabulary, what a trust policy that only checks aud is missing. A colleague writes a trust policy with the condition token.actions.githubusercontent.com:aud == sts.amazonaws.com, but no condition on sub. What real problem does that configuration have?

See solution

Checking only aud confirms the token was issued for AWS STS — but aud is the same value (sts.amazonaws.com) for any GitHub repository using this same OIDC-to-AWS pattern, not just andes-cargo/andes-cargo-infra. Without a condition on sub, that trust policy would accept a valid JWT issued for any GitHub Actions repository configured with OIDC to AWS, as long as that repository also points to this same identity provider — a much wider trust radius than Andes Cargo needs. The condition on sub, scoped to repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main, is what narrows that radius down to exactly one repository and one branch.

Exercise 3 — Predict the outcome of trying to assume the role from a Pull Request, not from main. With this lesson's trust policy (sub == repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main), what would happen if a workflow triggered by a Pull Request — not a push to main — tried to assume AndesCargoDeployRole?

See solution

It would fail with AccessDenied, and that's the correct behavior, not a bug. The sub of a JWT issued for a Pull Request event has a different format — typically repo:andes-cargo/andes-cargo-infra:pull_request, without the ref:refs/heads/main part — so it doesn't match the exact pattern the trust policy requires. This is precisely what makes this trust policy least-privilege: a role meant for applying changes to production (apply.yml, triggered by a push to main) shouldn't be assumable from a feature branch or from a still-unreviewed Pull Request — the same plan-on-PR/apply-on-merge discipline you already know from cicd-and-gitops-on-aws-guide.


Summary and next step

In this lesson you reviewed OIDC's complete mechanism with surgical precision on what you're going to build: the JWT's three exact claims (iss, aud, sub) and which question each answers; the complete four-step flow, with the two AWS resources — identity provider and trust policy — overlaid at the exact point where they act; and why those two resources are separate responsibilities that work together, neither sufficient on its own.

Before moving on you should be able to: explain which question each of the three claims (iss/aud/sub) answers; draw the complete flow marking where each AWS resource acts; and explain why a JWT's payload being public doesn't mean it's forgeable.

Lesson 4 declares this module's first real resource: aws_iam_openid_connect_provider, inside modules/oidc-provider/, with terraform fmt/init/validate/plan actually run.

Resources

  1. AWS Docs — Creating OpenID Connect (OIDC) identity providers — AWS's official documentation on the identity provider lesson 4 declares.
  2. AWS Docs — Creating a role for web identity or OpenID Connect Federation — AWS's official documentation on the trust policy lesson 5 declares, including Condition's complete syntax.
  3. GitHub Docs — About security hardening with OpenID Connect — GitHub's official explanation of the JWT's claims, including the full list available beyond iss/aud/sub.
  4. cicd-and-gitops-on-aws-guide, Module 4, lessons 4 and 5 — the complete conceptual mechanism and representative YAML this lesson builds on.