linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/lessons/tf-production-09-oidc

lesson ── terraform-production ── ~15 мин ── 5 шагов

OIDC, an IAM role for CI without access keys

On real AWS, GitHub Actions authenticates through OIDC: the workflow gets a JWT, STS exchanges it for temporary credentials, and there are no long-lived access keys. LocalStack Community does not support OIDC, so we emulate it. We create an IAM role with a trust policy for a federated identity, assume it through AssumeRole, and look at how it works. The concept is the same as on real AWS.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Create an IAM OIDC provider (emulation)

    On real AWS this is the command:

    aws iam create-open-id-connect-provider \
      --url https://token.actions.githubusercontent.com \
      --client-id-list sts.amazonaws.com

    On LocalStack, the same thing:

    bash
    cd /home/student/tf-oidc
    aws --endpoint-url=http://localstack:4566 \
      iam create-open-id-connect-provider \
      --url https://token.actions.githubusercontent.com \
      --client-id-list sts.amazonaws.com \
      --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 2>&1 || true
    aws --endpoint-url=http://localstack:4566 \
      iam list-open-id-connect-providers

    LocalStack may return a "not implemented" error, that is fine, Community does not support an OIDC provider. We keep going; what matters to us is the model, not the endpoint.

    Let's create a simple IAM role with a trust on AssumeRole (which LocalStack does support):

    bash
    cat > trust.json <<'EOF'
    {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": {"Service": "ec2.amazonaws.com"},
        "Action": "sts:AssumeRole"
      }]
    }
    EOF
    aws --endpoint-url=http://localstack:4566 \
      iam create-role \
      --role-name tf-runner-demo \
      --assume-role-policy-document file://trust.json
    aws --endpoint-url=http://localstack:4566 \
      iam attach-role-policy \
      --role-name tf-runner-demo \
      --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

    The role is created. It trusts the ec2 service (emulation; in real OIDC the trust would be to GitHub's federated identity).

    ✓ The role is created. Now we assume it.

  2. 02

    Assume the role, get temporary credentials

    bash
    CREDS=$(aws --endpoint-url=http://localstack:4566 \
      sts assume-role \
      --role-arn arn:aws:iam::000000000000:role/tf-runner-demo \
      --role-session-name local-demo)
    echo "$CREDS" | jq '.Credentials.AccessKeyId, .Credentials.SecretAccessKey | .[0:10]'

    We got temporary creds. They are short-lived (1 hour by default) and are not rotated the way ordinary access keys are.

    In real GitHub Actions this is done by aws-actions/configure-aws-credentials:

    yaml
    - uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-assume: arn:aws:iam::123:role/tf-runner-demo
        aws-region: us-east-1

    Under the hood, the exact same command sts assume-role-with-web-identity (with an OIDC JWT) or sts assume-role (for self-assumed).

    ✓ The temporary creds are issued. This is the model for CI without secrets.

  3. 03

    Use the temporary creds for terraform apply

    bash
    eval "$(aws --endpoint-url=http://localstack:4566 \
      sts assume-role \
      --role-arn arn:aws:iam::000000000000:role/tf-runner-demo \
      --role-session-name terraform-run | jq -r '
        .Credentials |
        "export AWS_ACCESS_KEY_ID=" + .AccessKeyId,
        "export AWS_SECRET_ACCESS_KEY=" + .SecretAccessKey,
        "export AWS_SESSION_TOKEN=" + .SessionToken
      ')"
    cat > main.tf <<'EOF'
    resource "aws_s3_bucket" "oidc_demo" {
      bucket = "linuxlab-oidc-demo"
    }
    EOF
    terraform init -no-color > /dev/null
    terraform apply -auto-approve -no-color
    terraform state list

    Terraform ran on temporary credentials, not on long-lived ones. This is what a real CI with OIDC does.

    ✓ Apply ran on temporary creds. The OIDC principle is in place.

  4. 04

    The trust policy in real OIDC

    On real AWS the trust looks different:

    bash
    cat > trust-real-oidc.json <<'EOF'
    {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": {
          "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
        },
        "Action": "sts:AssumeRoleWithWebIdentity",
        "Condition": {
          "StringEquals": {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
          },
          "StringLike": {
            "token.actions.githubusercontent.com:sub": "repo:linuxlab/terraform-infra:ref:refs/heads/main"
          }
        }
      }]
    }
    EOF
    cat trust-real-oidc.json | jq

    The key fields:

    • Federated, the ARN of GitHub's OIDC provider in your AWS account.
    • Action: sts:AssumeRoleWithWebIdentity, not AssumeRole, but federated.
    • Condition.StringEquals.aud, GitHub issues audience = sts.amazonaws.com.
    • Condition.StringLike.sub, the main filter; it allows assume only from a specific repo plus a specific ref/environment.

    The sub claim is what limits the blast radius. repo:org/*:* = vulnerable. repo:org/specific-repo:ref:refs/heads/main = tight.

    ✓ The trust policy for real OIDC is written. On real AWS, copy-paste.

    The same thing on OpenTofu

    OpenTofu keeps the CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back up the state and do a run on a feature branch, the differences cluster in the newer features (variables in backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  5. 05

    Separate roles per environment

    The antipattern, one role gh-tf-runner for dev+stage+prod. Compromise the dev CI and you compromise prod.

    The right way, three roles with different trust:

    bash
    cat > roles-design.md <<'EOF'
    | Role | Trust sub | Permissions |
    |---|---|---|
    | gh-tf-runner-dev | repo:linuxlab/infra:ref:refs/heads/* | dev-resources |
    | gh-tf-runner-stage | repo:linuxlab/infra:environment:stage | stage-resources |
    | gh-tf-runner-prod | repo:linuxlab/infra:environment:prod | prod-resources |
    EOF
    cat roles-design.md

    The workflow sets its own role-to-assume depending on the target env. Environment gating through the GitHub UI (required reviewers, branch filter) limits who can assume the prod role.

    On LocalStack we create a similar model, three separate roles:

    bash
    for env in dev stage prod; do
      aws --endpoint-url=http://localstack:4566 \
        iam create-role \
        --role-name "tf-runner-${env}" \
        --assume-role-policy-document file://trust.json
    done
    aws --endpoint-url=http://localstack:4566 iam list-roles \
      --query 'Roles[*].RoleName' --output text

    You see three tf-runner-* roles. In a real system each one is tied to a different environment in GitHub.

    ✓ The multi-role pattern is in place. In prod this gives you blast-radius isolation.

    A read-only role for the plan job

    A bonus pattern: the plan job does not need write permissions.

    gh-tf-runner-plan  , read-only (ReadOnlyAccess)
        trust: pull_request
    gh-tf-runner-apply , full (Custom managed policy)
        trust: push on main

    Plan on a PR runs under read-only, so even a compromised PR with malicious HCL cannot create anything. Apply is possible only after a merge into main (a different trust).

    In the trust policy:

    json
    "Condition": {
      "StringLike": {
        "token.actions.githubusercontent.com:sub": "repo:linuxlab/infra:pull_request"
      }
    }

    pull_request, the keyword in the sub claim for the PR context. Any PR in this repo gets access to the read-only role. The apply role requires :ref:refs/heads/main and is unreachable from the PR context.

    This shrinks the blast radius with a compromised contributor: they can open a PR with malicious HCL → the plan job runs it → a reviewer still has to approve the merge → the apply job is already on main → it is immediately clear what was applied. The audit trail is kept.

    • → OIDC + IAM in full
    • → Secrets in state and CI

Что ты узнал

LocalStack Community supports AssumeRole, but not real OIDC. What we do: an IAM role with minimal S3 permissions, assume it through sts assume-role, get temporary credentials. This is the model for how a real workflow gets creds through aws-actions/configure-aws-credentials.

команды

  • aws iam create-role ...create a role with a trust policy.
  • aws iam attach-role-policy ...attach permissions to the role.
  • aws sts assume-role --role-arn ...get temporary credentials.
  • AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... terraform applyuse the temporary creds for terraform.

концепции

  • · the trust policy defines WHO can assume; the permissions policy, WHAT they can do afterward
  • · in real GitHub OIDC the trust policy points to token.actions.githubusercontent.com
  • · the sub claim in real OIDC, repo:org/repo:ref:refs/heads/main

← предыдущий

The moved block: refactoring without recreation

следующий →

locals and functions: removing duplication in HCL

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies