GitHub Actions OIDC for Terraform Spatial Deploys

Long-lived AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pairs stored as GitHub secrets are the most common credential-exfiltration risk in a spatial deployment pipeline: they never expire, they leak through logs and forks, and they grant the same standing access whether the job is a routine tile-infra plan or an attacker’s fork. This guide replaces them with GitHub OIDC federation so a Terraform apply for PostGIS and tile infrastructure assumes a short-lived role scoped to one repository, branch, and environment. It is a step-by-step companion to Pipeline Orchestration for Spatial Deployments within the CI/CD, Drift Detection & Policy Governance framework, covering the exact IAM trust policy, aws-actions/configure-aws-credentials usage, subject-claim scoping, and a least-privilege permission set for spatial resources.

Symptom Identification and Triage

You are here for one of two reasons: you are eliminating static keys before they leak, or an OIDC setup is already failing to assume its role. The failures cluster into a few signatures, each with a distinct root cause. Read the exact error string before changing anything — the fix for a trust-policy mismatch is the opposite of the fix for a missing token permission.

  • Not authorized to perform sts:AssumeRoleWithWebIdentity — the OIDC provider exists but the role’s trust policy does not match this workflow’s claims. The sub condition, the aud value, or the provider ARN is wrong. This is the most common case and the one this guide centres on.
  • Error: Credentials could not be loaded / no token requested — the workflow job is missing permissions: id-token: write, so GitHub never mints an OIDC token for configure-aws-credentials to exchange. A pure workflow-file fix, no IAM involved.
  • AccessDenied on s3:PutObject / rds:ModifyDBInstance after a successful assume — federation worked, but the assumed role’s permission policy is too tight for the spatial resources the apply touches. CloudTrail shows AssumeRoleWithWebIdentity succeeding and the later data-plane call failing.
  • InvalidIdentityToken / audience mismatch — the aud claim (sts.amazonaws.com when using configure-aws-credentials) does not match the audience condition in the trust policy, or a customized audience was set on one side only.
OIDC failure triage: workflow permission, trust policy, or permission policy A top-down decision tree. The symptom is a GitHub Actions job failing to reach AWS. First decision: did the job request an OIDC token? If no, the job is missing permissions id-token write and the fix is in the workflow file. If yes, second decision: does CloudTrail record an AssumeRoleWithWebIdentity event? If the event is absent or errored, the trust policy sub, aud, or provider ARN does not match and the fix is the role trust policy. If the assume event succeeded but a later call failed with AccessDenied, the role's permission policy is too narrow for the spatial resource and the fix is the permission policy. OIDC apply fails cannot reach AWS OIDC token requested? Fix: workflow file add permissions: id-token: write no AssumeRoleWith- WebIdentity in CloudTrail? yes Fix: trust policy sub / aud / provider ARN mismatch absent / errored Fix: permission policy role too narrow for spatial resource succeeded, later AccessDenied

Prerequisites and Environment Assumptions

This guide assumes an AWS target provisioned by Terraform, where the apply manages PostGIS (RDS or Aurora), object storage for tiles and rasters, and the CloudFront/ALB layer that fronts them. To follow it you need:

  • Terraform >= 1.10.0 with the AWS provider pinned. Unpinned providers cause phantom diffs on IAM resources, so pin the provider in required_providers:

    terraform {
      required_version = ">= 1.10.0"
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.60"   # pin so IAM/OIDC resources render reproducibly
        }
        tls = {
          source  = "hashicorp/tls"
          version = "~> 4.0"    # used to read the provider thumbprint (optional on modern AWS)
        }
      }
    }
  • The aws-actions/configure-aws-credentials action pinned by tag or SHA (v4.0.2 at time of writing), which requests the OIDC token and exchanges it for temporary credentials via sts:AssumeRoleWithWebIdentity.

  • permissions: id-token: write available to the workflow — this cannot be granted to workflows triggered from forks by default, which is intentional: forked PRs must never assume a deploy role.

  • IAM permissions for the operator doing this setup: iam:CreateOpenIDConnectProvider, iam:CreateRole, iam:PutRolePolicy/iam:AttachRolePolicy, and cloudtrail:LookupEvents for verification.

  • A locking-capable state backend as established in State Backend Selection; the apply role needs read/write to that backend object and its lock, scoped in the permission policy below.

Step-by-Step Remediation

Build the provider once per account, then a role per pipeline scoped tightly to the repository, branch, and environment that is allowed to deploy.

  1. Register GitHub as an OIDC identity provider in AWS. This is a one-time, account-wide resource. AWS now validates GitHub’s certificate against its own trust store, so a hand-maintained thumbprint is no longer load-bearing, but the provider still declares the issuer URL and the audience.

    data "tls_certificate" "github" {
      url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
    }
    
    resource "aws_iam_openid_connect_provider" "github" {
      url             = "https://token.actions.githubusercontent.com"
      client_id_list  = ["sts.amazonaws.com"]  # the aud claim configure-aws-credentials sends
      thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
    }

    The client_id_list value sts.amazonaws.com is the audience the assume call presents; if it does not match, every assume fails with an audience error before any spatial resource is touched.

  2. Write the trust policy scoped to one repo, branch, and environment. This is the security boundary. The sub (subject) claim encodes exactly which workflow context may assume the role. Scoping to environment:production (rather than a branch) ties the role to the same GitHub environment protection rule that gates the apply, so a human approval is required before the token that can assume this role is ever minted.

    data "aws_iam_policy_document" "trust" {
      statement {
        effect  = "Allow"
        actions = ["sts:AssumeRoleWithWebIdentity"]
    
        principals {
          type        = "Federated"
          identifiers = [aws_iam_openid_connect_provider.github.arn]
        }
    
        # audience must equal the aud claim from step 1
        condition {
          test     = "StringEquals"
          variable = "token.actions.githubusercontent.com:aud"
          values   = ["sts.amazonaws.com"]
        }
    
        # subject: scope to ONE repo + environment. Never use a bare repo:org/repo:*
        condition {
          test     = "StringEquals"
          variable = "token.actions.githubusercontent.com:sub"
          values   = ["repo:acme-gis/tile-infra:environment:production"]
        }
      }
    }
    
    resource "aws_iam_role" "ci_apply" {
      name                 = "spatial-ci-apply"
      assume_role_policy   = data.aws_iam_policy_document.trust.json
      max_session_duration = 3600  # 1h: long enough for a tile build, short enough to limit exposure
    }

    Note: the sub value must match the workflow’s actual context string exactly. For a branch-scoped role it is repo:acme-gis/tile-infra:ref:refs/heads/main; for an environment-scoped role it is repo:acme-gis/tile-infra:environment:production. A wildcard such as repo:acme-gis/tile-infra:* would let any branch — including an attacker’s PR branch — assume a production deploy role, which defeats the purpose of federation.

  3. Attach a least-privilege permission policy for the spatial resources. Federation only decides who may assume the role; this policy decides what the apply may do. Grant exactly the actions the spatial estate’s apply needs — state access, the RDS/PostGIS modifications, tile and raster object storage, and the CDN — and nothing wildcard.

    data "aws_iam_policy_document" "apply_perms" {
      # Terraform state backend: read/write the state object + lock
      statement {
        sid       = "StateBackend"
        actions   = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
        resources = [
          "arn:aws:s3:::spatial-iac-state-prod",
          "arn:aws:s3:::spatial-iac-state-prod/prod/*",
        ]
      }
      # PostGIS cluster lifecycle
      statement {
        sid       = "PostGIS"
        actions   = ["rds:DescribeDBInstances", "rds:ModifyDBInstance", "rds:CreateDBInstance"]
        resources = ["arn:aws:rds:us-east-1:123456789012:db:prod-postgis-*"]
      }
      # tile + raster object storage (data plane the apply manages)
      statement {
        sid       = "TileStorage"
        actions   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:PutLifecycleConfiguration"]
        resources = ["arn:aws:s3:::prod-gis-tiles/*", "arn:aws:s3:::prod-gis-raster-store/*"]
      }
      # CDN fronting the tiles
      statement {
        sid       = "TileCDN"
        actions   = ["cloudfront:GetDistribution", "cloudfront:UpdateDistribution"]
        resources = ["arn:aws:cloudfront::123456789012:distribution/E123TILECDN"]
      }
    }
    
    resource "aws_iam_role_policy" "ci_apply" {
      name   = "spatial-apply-least-priv"
      role   = aws_iam_role.ci_apply.id
      policy = data.aws_iam_policy_document.apply_perms.json
    }

    The scoping here mirrors the identity design in IAM Role Mapping for GIS: the deploy role is separate from the data-plane roles that tile servers assume at runtime, so a compromise of one does not grant the other.

  4. Wire the workflow to assume the role. The job requests the OIDC token (id-token: write), the action exchanges it, and Terraform runs with the temporary credentials. No AWS_SECRET_ACCESS_KEY appears anywhere.

    jobs:
      apply:
        runs-on: [self-hosted, in-vpc]
        environment: production        # the protection rule that gates the sub claim
        permissions:
          id-token: write              # mint the OIDC token
          contents: read
        steps:
          - uses: actions/checkout@v4.2.2
          - uses: aws-actions/configure-aws-credentials@v4.0.2
            with:
              role-to-assume: arn:aws:iam::123456789012:role/spatial-ci-apply
              aws-region: us-east-1
              # no aws-access-key-id / aws-secret-access-key — federation only
          - uses: hashicorp/setup-terraform@v3.1.2
            with:
              terraform_version: 1.10.5
          - run: terraform init -backend-config=env/prod.s3.tfbackend
          - run: terraform apply -var-file=env/prod.tfvars -auto-approve

Minimal reproduction

To prove federation independently of any Terraform run, add a one-line assume-and-print step to a throwaway workflow. If it prints an assumed-role ARN, the trust policy and token permission are correct and any remaining failure is a permission-policy problem.

# minimal repro: confirm the role can be assumed at all
jobs:
  whoami:
    runs-on: ubuntu-24.04
    permissions: { id-token: write, contents: read }
    steps:
      - uses: aws-actions/configure-aws-credentials@v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/spatial-ci-apply
          aws-region: us-east-1
      - run: aws sts get-caller-identity   # prints assumed-role/spatial-ci-apply/... on success

Verification

Confirm federation worked at the identity layer before trusting the apply.

  1. Check get-caller-identity output. The minimal repro above must print an ARN of the form arn:aws:sts::123456789012:assumed-role/spatial-ci-apply/<session>. Anything else — an error, or a different role — means the assume did not resolve to the intended role.

  2. Confirm the AssumeRoleWithWebIdentity event in CloudTrail. This is the authoritative proof that federation, not a static key, granted access. Look up the event and verify the principal, the federated provider, and an absent errorCode:

    aws cloudtrail lookup-events \
      --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
      --query 'Events[0].CloudTrailEvent' --output text | \
      python3 -c "import sys,json; e=json.load(sys.stdin); \
        print(e['userIdentity'].get('arn'), e.get('errorCode','OK'), \
              e['requestParameters'].get('roleArn'))"

    A healthy result shows the spatial-ci-apply role ARN and OK. An errorCode of AccessDenied here means the trust policy rejected the claims — recheck the sub and aud conditions against the workflow context.

  3. Confirm no static keys remain. Delete any AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY repository and environment secrets, then re-run the apply. If it still succeeds, the pipeline is genuinely keyless; if it now fails, a job was silently depending on the old keys and must be migrated.

Preventing Recurrence

  • Scope every deploy role to a single sub. A wildcard subject is the failure that reintroduces the risk keys posed. Add an OPA or Conftest rule to the Policy as Code for Spatial Resources gate that fails any aws_iam_role trust policy whose sub condition contains *.
  • Keep max_session_duration short. One hour covers a long tile build; going longer widens the window a leaked token is usable. Never raise it to reduce assume frequency.
  • Separate plan and apply roles. The plan role is read-only and reachable from any PR; the apply role is write-scoped and reachable only from the protected environment. This split is enforced in Pipeline Orchestration for Spatial Deployments.
  • Store what OIDC cannot replace in a real secret store. Federation removes cloud credentials, but database passwords and third-party API keys still need managed rotation — see Secrets and Credential Management for Spatial Pipelines.

Frequently asked questions

Do I still need an AWS_SECRET_ACCESS_KEY anywhere with OIDC?

No. aws-actions/configure-aws-credentials requests a short-lived OIDC token from GitHub and exchanges it for temporary STS credentials via sts:AssumeRoleWithWebIdentity. Once federation is confirmed with get-caller-identity, delete every static AWS key secret; if the pipeline still works, it is fully keyless.

Should I scope the trust policy to a branch or a GitHub environment?

Prefer the environment (repo:org/repo:environment:production) for apply roles, because it ties the role to the environment protection rule that requires human approval before the token is minted. Branch scoping (ref:refs/heads/main) is fine for plan roles. Never use a bare repo:org/repo:* wildcard — it lets any branch, including a fork’s, assume the role.

The role assumes fine but Terraform apply gets AccessDenied on RDS — why?

Federation and permissions are separate. A successful AssumeRoleWithWebIdentity in CloudTrail followed by an AccessDenied on rds:ModifyDBInstance means the trust policy is correct but the role’s permission policy is too narrow for the spatial resource. Add the specific action and resource ARN to the least-privilege policy in step 3, not a wildcard.

Why does my job fail with "Credentials could not be loaded" before hitting AWS?

The job is missing permissions: id-token: write, so GitHub never issues an OIDC token for the action to exchange. Add it at the job or workflow level. This is a workflow-file fix with no IAM change; note that fork-triggered runs cannot get this permission by design.