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. Thesubcondition, theaudvalue, 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 missingpermissions: id-token: write, so GitHub never mints an OIDC token forconfigure-aws-credentialsto exchange. A pure workflow-file fix, no IAM involved.AccessDeniedons3:PutObject/rds:ModifyDBInstanceafter a successful assume — federation worked, but the assumed role’s permission policy is too tight for the spatial resources the apply touches. CloudTrail showsAssumeRoleWithWebIdentitysucceeding and the later data-plane call failing.InvalidIdentityToken/ audience mismatch — theaudclaim (sts.amazonaws.comwhen usingconfigure-aws-credentials) does not match the audience condition in the trust policy, or a customized audience was set on one side only.
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.0with the AWS provider pinned. Unpinned providers cause phantom diffs on IAM resources, so pin the provider inrequired_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-credentialsaction pinned by tag or SHA (v4.0.2at time of writing), which requests the OIDC token and exchanges it for temporary credentials viasts:AssumeRoleWithWebIdentity. -
permissions: id-token: writeavailable 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, andcloudtrail:LookupEventsfor 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.
-
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_listvaluests.amazonaws.comis the audience the assume call presents; if it does not match, every assume fails with an audience error before any spatial resource is touched. -
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 toenvironment: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
subvalue must match the workflow’s actual context string exactly. For a branch-scoped role it isrepo:acme-gis/tile-infra:ref:refs/heads/main; for an environment-scoped role it isrepo:acme-gis/tile-infra:environment:production. A wildcard such asrepo: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. -
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.
-
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. NoAWS_SECRET_ACCESS_KEYappears 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.
-
Check
get-caller-identityoutput. The minimal repro above must print an ARN of the formarn: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. -
Confirm the
AssumeRoleWithWebIdentityevent 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 absenterrorCode: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-applyrole ARN andOK. AnerrorCodeofAccessDeniedhere means the trust policy rejected the claims — recheck thesubandaudconditions against the workflow context. -
Confirm no static keys remain. Delete any
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYrepository 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 anyaws_iam_roletrust policy whosesubcondition contains*. - Keep
max_session_durationshort. 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.
Related
- Pipeline Orchestration for Spatial Deployments — the plan/apply promotion pipeline these federated roles authenticate into.
- IAM Role Mapping for GIS — separating deploy identities from the data-plane roles tile servers assume at runtime.
- Secrets and Credential Management for Spatial Pipelines — managing the database and API secrets OIDC does not replace.
- State Backend Selection — the locking backend the apply role is scoped to read and write.