Resource library

QA How-To

GitHub Actions OIDC for Test Environments (2026)

Set up GitHub Actions OIDC for test environments with AWS IAM, protected environments, short-lived credentials, least privilege, and safe verification.

20 min read | 2,534 words

TL;DR

GitHub Actions OIDC exchanges a job-specific GitHub token for temporary AWS credentials, so you do not store access keys. Bind the AWS role to `repo:OWNER/REPO:environment:test`, protect that GitHub environment, and give the role only the test-resource actions the workflow needs.

Key Takeaways

  • Use a protected GitHub environment so the OIDC subject identifies the exact test environment.
  • Match both the token audience and subject in the AWS role trust policy.
  • Grant id-token: write only to the job that needs cloud authentication.
  • Keep the role permission policy narrower than its trust policy and limited to test resources.
  • Verify the caller identity and expected account before running tests.
  • Use CloudTrail and explicit session names to trace each workflow run.

GitHub Actions OIDC for test environments removes long-lived AWS access keys from repository secrets. A workflow requests a signed GitHub identity token, AWS validates its claims, and AWS STS returns temporary role credentials for that job only.

This tutorial builds the full path for an AWS test environment. You will constrain trust to one repository and the GitHub environment named test, add a narrow permission policy, run a smoke test, and prove that an untrusted context cannot obtain credentials. If you are also building the test suite itself, review GitHub Actions for Playwright.

TL;DR

Control Value in this tutorial Why it matters
GitHub environment test Makes the environment name part of the OIDC subject
OIDC issuer https://token.actions.githubusercontent.com Identifies GitHub as token issuer
Audience sts.amazonaws.com Prevents a token intended for another service from being accepted
Subject repo:acme/shop-web:environment:test Limits role assumption to one repository and environment
Workflow permission id-token: write Allows token request, not repository writes
AWS session 15 minutes Limits exposure if a job is compromised

OIDC changes authentication, not authorization. The trust policy decides who may assume the role. The role permission policy decides what an authenticated job may do. You need both layers to be restrictive.

What You Will Build

By the end, you will have:

  • An AWS IAM OIDC provider for GitHub Actions.
  • A qa-test-runner role trusted only by acme/shop-web through the test GitHub environment.
  • A policy that can read test configuration from one S3 prefix and invoke one API Gateway stage.
  • A GitHub Actions workflow that obtains temporary credentials and runs a smoke assertion.
  • Verification commands for identity, expiration, account ownership, and negative access.

The sample names are deliberately concrete. Replace account 123456789012, repository acme/shop-web, region us-east-1, bucket acme-qa-config, API ID abc123xyz, and test URL with your values. Do not broaden wildcards merely to make the first run pass.

Prerequisites

Use GitHub-hosted ubuntu-24.04, AWS CLI 2.31.x or newer, Node.js 24.x, and aws-actions/configure-aws-credentials@v6.1.1. Version 6 uses the Node 24 action runtime and requires self-hosted Actions Runner 2.327.1 or newer. GitHub-hosted runners already carry a compatible runner.

You also need administrator permission to create an AWS identity provider and role, repository admin permission to create environments and variables, and a test endpoint that returns JSON such as {"status":"ok"}. Authenticate the CLIs locally:

gh --version
aws --version
aws sts get-caller-identity

Never paste local AWS credentials into a workflow. The local administrator identity is used only for one-time IAM setup. Readers new to delivery pipelines can first use the DevOps for QA roadmap.

Step 1: Create and Protect the GitHub Test Environment

Create an environment named exactly test. Environment names affect the OIDC sub claim, so case and punctuation must agree across GitHub, IAM, and YAML.

gh api --method PUT \
  -H "Accept: application/vnd.github+json" \
  /repos/acme/shop-web/environments/test

In repository Settings, open Environments, then test. Add deployment branch protection for main. For a sensitive shared QA account, add required reviewers and disable self-review. Add these environment variables, which are non-secret identifiers:

  • AWS_ROLE_ARN: arn:aws:iam::123456789012:role/qa-test-runner
  • AWS_REGION: us-east-1
  • TEST_BASE_URL: https://test.example.com

A GitHub environment is more than a label. A job that declares environment: test receives the environment-form subject repo:acme/shop-web:environment:test. If the job uses only a branch and no environment, its subject instead resembles repo:acme/shop-web:ref:refs/heads/main, which will not match the role created below.

Verify: Run gh api /repos/acme/shop-web/environments/test --jq '.name'. The output must be test. Then inspect the environment in the UI and confirm only the intended deployment branch can enter it.

Step 2: Register GitHub as an AWS OIDC Provider

AWS must recognize GitHub's issuer before a role can trust it. Check whether the provider already exists because each AWS account needs only one provider for this issuer.

ACCOUNT_ID=123456789012
PROVIDER_ARN=arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com

aws iam get-open-id-connect-provider \
  --open-id-connect-provider-arn "${PROVIDER_ARN}"

If AWS returns NoSuchEntity, create it:

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

The URL becomes the issuer, or iss, and the client ID becomes the expected audience, or aud. Current AWS setup does not require you to supply GitHub's TLS certificate thumbprint. Do not create a second provider with a slightly different URL.

Verify: Query the provider and check its client IDs:

aws iam get-open-id-connect-provider \
  --open-id-connect-provider-arn "${PROVIDER_ARN}" \
  --query 'ClientIDList' --output json

The returned array must contain sts.amazonaws.com. An empty list or custom audience will make AssumeRoleWithWebIdentity fail.

Step 3: Create an Exact OIDC Trust Policy

Create trust-policy.json locally. This policy allows federation only when both audience and subject match.

{
  "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",
          "token.actions.githubusercontent.com:sub": "repo:acme/shop-web:environment:test"
        }
      }
    }
  ]
}

Create the role with a short maximum session. AWS permits a minimum role maximum of 3600 seconds, while the action can request a shorter session duration. The workflow later asks for 900 seconds.

aws iam create-role \
  --role-name qa-test-runner \
  --assume-role-policy-document file://trust-policy.json \
  --max-session-duration 3600 \
  --description "OIDC role for shop-web test environment checks"

Do not use repo:acme/* or repo:acme/shop-web:* for convenience. Those patterns admit more repositories or contexts than this job needs. Pull request jobs from forks are especially important to exclude from cloud roles.

Verify: Run aws iam get-role --role-name qa-test-runner --query 'Role.AssumeRolePolicyDocument.Statement[0].Condition'. Confirm the decoded subject ends in environment:test, not ref:refs/heads/main and not pull_request.

Step 4: Attach Least-Privilege Test Permissions

Trust does not grant S3 or API access. Create qa-test-permissions.json for the exact resources used by the smoke suite:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadTestConfig",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::acme-qa-config/shop-web/test/*"
    },
    {
      "Sid": "InvokeTestApi",
      "Effect": "Allow",
      "Action": ["execute-api:Invoke"],
      "Resource": "arn:aws:execute-api:us-east-1:123456789012:abc123xyz/test/GET/health"
    }
  ]
}

Attach it as an inline role policy:

aws iam put-role-policy \
  --role-name qa-test-runner \
  --policy-name qa-test-smoke-access \
  --policy-document file://qa-test-permissions.json

Separate roles by environment. A test role should not modify production deployments, read production customer exports, or administer IAM. If tests provision fixtures, list only the create, read, and cleanup actions for resources carrying an unambiguous test prefix. Testing the authorization boundary itself is part of API security testing basics.

Verify: Use aws iam get-role-policy --role-name qa-test-runner --policy-name qa-test-smoke-access. Review every action and resource. The document should contain neither Action: * nor Resource: *.

Step 5: Add the GitHub Actions OIDC Workflow

Create .github/workflows/test-environment-smoke.yml in your application repository:

name: Test environment smoke

on:
  workflow_dispatch:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: test-environment-smoke
  cancel-in-progress: false

jobs:
  smoke:
    runs-on: ubuntu-24.04
    environment: test
    timeout-minutes: 15
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Configure temporary AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.1.1
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          role-duration-seconds: 900
          role-session-name: gha-${{ github.run_id }}-${{ github.run_attempt }}
          allowed-account-ids: 123456789012

      - name: Verify AWS identity
        shell: bash
        run: |
          set -euo pipefail
          account=$(aws sts get-caller-identity --query Account --output text)
          arn=$(aws sts get-caller-identity --query Arn --output text)
          test "$account" = "123456789012"
          [[ "$arn" == *":assumed-role/qa-test-runner/"* ]]
          echo "Authenticated as $arn"

      - name: Download test configuration
        run: |
          aws s3 cp \
            s3://acme-qa-config/shop-web/test/smoke.json \
            ./smoke.json

      - name: Assert test endpoint health
        env:
          TEST_BASE_URL: ${{ vars.TEST_BASE_URL }}
        run: |
          node --input-type=module <<'NODE'
          const response = await fetch(`${process.env.TEST_BASE_URL}/health`, {
            signal: AbortSignal.timeout(10_000)
          });
          if (!response.ok) throw new Error(`HTTP ${response.status}`);
          const body = await response.json();
          if (body.status !== 'ok') throw new Error(`Unexpected body: ${JSON.stringify(body)}`);
          console.log('Test environment is healthy');
          NODE

The top-level permission defaults every job to read-only repository access. The job adds id-token: write only where required. That permission lets GitHub mint an OIDC token; it does not let the workflow edit repository contents. The action exchanges that token with AWS STS and exports temporary credentials for later steps.

Pinning a full commit SHA offers stronger supply-chain immutability than a version tag. If your organization mandates SHA pinning, resolve the verified release commit and let Dependabot update it. Never copy an unverified SHA from a blog post.

Verify: Dispatch the workflow from Actions. After any environment approval, the identity step should print an ARN containing assumed-role/qa-test-runner/gha-. The final step should print Test environment is healthy.

Step 6: Prove Credentials Are Temporary and Scoped

A green test is incomplete evidence. Add a diagnostic step temporarily after authentication:

      - name: Check credential lifetime and denied access
        shell: bash
        run: |
          set -euo pipefail
          test -n "${AWS_SESSION_TOKEN:-}"
          aws sts get-caller-identity

          if aws s3 ls s3://acme-production-data/; then
            echo "ERROR: test role reached production data" >&2
            exit 1
          else
            echo "Expected denial for production bucket"
          fi

AWS_SESSION_TOKEN distinguishes an STS session from a static access-key pair. The denied production request confirms the permission boundary behaves as designed. Choose a harmless read operation for negative testing. Do not deliberately invoke a mutating production API.

Next, copy the workflow to a temporary branch and remove environment: test. Dispatching that altered job should fail at credential configuration because its branch-based sub does not equal the trusted environment subject. Restore the file afterward. This negative test proves the trust policy, not just the permission policy.

Verify: Confirm the permitted S3 object downloads, the production bucket returns AccessDenied, and the workflow without the environment cannot assume the role. Three distinct results validate authentication, authorization, and context binding.

Step 7: Add Auditability and Safe Test Operations

Keep the explicit role session name. In AWS CloudTrail, AssumeRoleWithWebIdentity events record the role session, issuer context, and request details. The GitHub run ID and attempt in gha-<run>-<attempt> let an investigator find the matching workflow log.

Set CloudTrail coverage according to your organization's AWS baseline and retain logs outside the account under test when possible. Alert on unexpected role assumption patterns, access outside normal test regions, and repeated denied actions. Do not print the OIDC JWT, AWS_SECRET_ACCESS_KEY, or AWS_SESSION_TOKEN; GitHub masking is a safety net, not permission to log credentials.

For parallel tests, use GitHub Actions matrix testing, but authenticate once per job and avoid sharing credential files through artifacts. For dependency-heavy pipelines, apply GitHub Actions caching for faster tests only to immutable dependencies and build outputs, never to ~/.aws or token files. If the environment runs locally as multiple services, Docker Compose for test environments provides a reproducible counterpart.

Verify: Find the latest AssumeRoleWithWebIdentity event in CloudTrail and match its session name to the Actions run URL. Confirm workflow logs contain the account and assumed-role ARN but no token or secret value.

Step 8: Test Revocation, Drift, and Cleanup

An identity design is operationally complete only when you can remove access quickly and detect configuration drift. Practice revocation before an incident. The safest reversible test is to change the role trust subject to an impossible environment name, confirm the workflow loses access, then restore the reviewed policy.

First, save the current trust document in a form AWS can accept later:

aws iam get-role \
  --role-name qa-test-runner \
  --query 'Role.AssumeRolePolicyDocument' \
  --output json > trust-policy-backup.json

cp trust-policy-backup.json trust-policy-revoked.json

Edit only the subject in trust-policy-revoked.json, changing environment:test to environment:revoked-test. Apply the temporary denial:

aws iam update-assume-role-policy \
  --role-name qa-test-runner \
  --policy-document file://trust-policy-revoked.json

Dispatch the smoke workflow. The credential action must fail before S3 access or the endpoint test begins. Restore the original policy immediately after collecting the result:

aws iam update-assume-role-policy \
  --role-name qa-test-runner \
  --policy-document file://trust-policy-backup.json

Revoking future assumptions does not invalidate credentials that STS already issued. If immediate containment is required, use the AWS role credential revocation mechanism, inspect active work, disable affected workflows, and deny sensitive operations through resource policies or service controls according to your incident runbook. This is another reason to request 15-minute sessions for smoke jobs instead of the role's full one-hour maximum.

Automate drift detection without placing administrative IAM permissions on the test runner. A separate security workflow or infrastructure pipeline can compare the committed trust and permission documents with AWS. For a lightweight review, retrieve both documents and make assertions:

actual_sub=$(aws iam get-role \
  --role-name qa-test-runner \
  --query 'Role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals."token.actions.githubusercontent.com:sub"' \
  --output text)

test "$actual_sub" = "repo:acme/shop-web:environment:test"

aws iam get-role-policy \
  --role-name qa-test-runner \
  --policy-name qa-test-smoke-access \
  --query 'PolicyDocument' \
  --output json > actual-permissions.json

if jq -e '.. | objects | select(.Action? == "*" or .Resource? == "*")' \
  actual-permissions.json >/dev/null; then
  echo "Wildcard permission detected" >&2
  exit 1
fi

Treat that wildcard check as a guardrail, not a complete policy analyzer. Arrays can contain partial wildcards, conditions can expand access, and an apparently narrow ARN can still represent a large resource set. Store IAM configuration as reviewed infrastructure code when possible and use AWS IAM Access Analyzer policy validation during changes.

Define ownership and deletion criteria too. When the test environment is retired, remove its GitHub environment, disable its workflows, delete the inline permission policy, then delete the role. Keep the account-level GitHub OIDC provider if other roles still reference it. Before deleting a provider, inspect every role that trusts its ARN so you do not break unrelated pipelines.

Verify: After restoring the valid trust policy, dispatch the smoke workflow again. It must authenticate successfully. Confirm the temporarily revoked run failed at role assumption, the restored run passed, and the drift checks report the exact subject with no wildcard action or resource.

GitHub Actions OIDC for Test Environments: Security Model

Static keys and OIDC fail differently. Understanding the trade-off prevents a cosmetic migration that leaves broad access intact.

Property Stored access keys GitHub OIDC
Credential lifetime Until rotation or revocation One STS session
Secret in GitHub Access key and secret key No cloud credential
Context binding Usually none Repository, environment, branch, or other claims
Revocation Rotate key or disable principal Change trust policy, environment, or role
Main risk Secret theft and forgotten rotation Overbroad trust or compromised authorized workflow
Audit identity Shared IAM user is common Role session maps to workflow run

OIDC does not make workflow code trusted. Anyone able to change an authorized workflow might use its role, subject to branch rules, environment reviewers, CODEOWNERS, and repository permissions. Protect .github/workflows/**, pin third-party actions, minimize token permissions, and avoid evaluating untrusted pull request code after cloud authentication.

Reusable workflows need extra care. The caller's claims and job_workflow_ref can support stronger restrictions, but claim customization must be coordinated with the cloud trust policy before activation. A mismatch locks out every job. Start with the default environment subject unless your organization has a documented reusable-workflow identity standard.

Troubleshooting

Problem: Credentials could not be loaded or the action says OIDC is unavailable -> Add permissions: id-token: write to the job. If a parent workflow sets restrictive permissions, confirm the called workflow receives the permission. Also ensure you did not set force-skip-oidc.

Problem: Not authorized to perform sts:AssumeRoleWithWebIdentity -> Compare the actual context with the trust subject. A job using environment: test needs repo:OWNER/REPO:environment:test; a branch subject will not match. Check owner, repository, and environment capitalization.

Problem: AWS reports an audience mismatch -> Keep the provider client ID, trust condition, and action audience aligned. Standard AWS partitions use sts.amazonaws.com; China partitions use sts.amazonaws.com.cn and require the corresponding action input.

Problem: The role assumes successfully but S3 or API calls return AccessDenied -> Authentication worked. Inspect the role permission policy, exact resource ARN, region, API stage, HTTP method, and S3 object prefix. Do not weaken the trust policy to solve an authorization failure.

Problem: The workflow waits for approval or says the branch cannot deploy -> Review the test environment protection rules. Add the intended branch or reviewer. Keep the control if the role reaches shared infrastructure; bypassing it defeats the environment boundary.

Problem: Version 6 fails on a self-hosted runner before credential exchange -> Upgrade the Actions Runner to 2.327.1 or newer because the action uses Node 24. If runner upgrades are centrally managed, coordinate the change instead of downgrading silently.

Interview Questions and Answers

The model answers in the structured interview section below cover claim validation, trust versus permissions, fork safety, audit trails, and negative verification. In an interview, explain the whole exchange and name the boundary you would test rather than saying only that OIDC is more secure.

Best Practices

  • Use one role per environment and workload boundary. A shared organization-wide test role makes incident scope and ownership unclear.
  • Require exact aud and sub conditions. Add supported claims only when they enforce a real policy.
  • Put id-token: write on the smallest possible job, not at organization-wide workflow scope.
  • Apply CODEOWNERS review to workflows and infrastructure trust policies.
  • Set allowed-account-ids so a configuration error cannot silently authenticate to the wrong AWS account.
  • Request a short role session that comfortably covers the job, and set a workflow timeout too.
  • Run positive and negative authorization assertions after each meaningful IAM policy change.
  • Keep test data synthetic or minimized even when the environment is non-production.

Where To Go Next

Your test workflow now authenticates without stored AWS keys, accepts only the intended GitHub environment, and operates through a narrow role. Apply the same design separately to staging, with a distinct GitHub environment, AWS role, account allowlist, and resource policy. Do not turn the test subject into a wildcard.

Next, integrate the identity step into GitHub Actions for Playwright, scale browser combinations with GitHub Actions matrix testing, and reduce safe dependency work with GitHub Actions caching. Keep the identity and denial checks as permanent guardrails, not one-time setup diagnostics.

Interview Questions and Answers

Explain the GitHub Actions to AWS OIDC exchange.

The job receives permission to request a GitHub-signed JWT. AWS validates the issuer, audience, subject, signature, and time claims against its OIDC provider and role trust policy. STS then returns temporary role credentials, and the role permission policy limits AWS API access.

What is the difference between an IAM trust policy and a role permission policy?

The trust policy controls which identity may assume the role, such as one GitHub repository environment. The permission policy controls what the assumed session may do after authentication. A secure design narrows both because a strict trust policy cannot compensate for unnecessary AWS actions.

Why use a GitHub environment in the OIDC subject?

It binds cloud authentication to a named deployment boundary that can have branch restrictions, reviewers, and environment-scoped configuration. The resulting subject is stable and explicit, such as `repo:acme/shop-web:environment:test`. That is stronger than accepting every ref in the repository.

How would you test an OIDC configuration beyond a successful deployment?

I would assert the AWS account and assumed-role ARN, confirm a permitted test resource is accessible, and verify an unrelated production resource is denied. I would also remove the job's environment in a controlled branch and confirm role assumption fails due to subject mismatch.

What are the main residual risks after replacing static keys with OIDC?

An overbroad subject, excessive role permissions, unprotected workflow changes, and untrusted code execution can still lead to misuse. I mitigate them with exact claim conditions, least privilege, environment review, CODEOWNERS, pinned actions, short sessions, and CloudTrail monitoring.

How do you correlate an AWS session with a GitHub Actions run?

Set the role session name to include `github.run_id` and `github.run_attempt`. Then locate the corresponding `AssumeRoleWithWebIdentity` event in CloudTrail and match that session to the workflow URL. Logs should expose identifiers, never the JWT or temporary secret values.

Frequently Asked Questions

What is GitHub Actions OIDC for test environments?

It is a federation flow in which a GitHub Actions job presents a signed identity token to a cloud provider and receives temporary credentials. For a protected test environment, the token subject can identify the exact repository and environment, avoiding stored cloud access keys.

Does `id-token: write` let a workflow modify the repository?

No. It permits the job to request an OIDC token from GitHub's token service. Repository access is controlled separately by permissions such as `contents: read` or `contents: write`.

What subject should AWS trust for a GitHub environment named test?

The default subject is `repo:OWNER/REPOSITORY:environment:test`. Match it with `StringEquals` when only that environment should assume the role, and replace the owner and repository with exact case-sensitive values.

Should an AWS role trust both the main branch and the test environment subject?

Usually the environment subject is sufficient because GitHub environment deployment rules can restrict allowed branches. Adding a branch-form subject creates another authentication path, so include it only when a separate job genuinely needs it.

Can pull requests from forks use the OIDC role?

Not with the exact environment subject and appropriate environment protections shown here. Still avoid running untrusted pull request code after authentication, because an authorized workflow step can use every permission granted to its role.

How long should OIDC credentials last for automated tests?

Request the shortest session that reliably covers setup, execution, and cleanup. This tutorial uses 900 seconds for a smoke test; a longer suite may need more, but its workflow timeout and role duration should remain bounded.

Related Guides