TL;DR
- Long-lived
AKIAkeys in GitHub Secrets are standing privilege: every workflow that reads the secret inherits cloud access until someone rotates keys. - OIDC federation issues short-lived STS credentials bound to repository, branch, or environment claims in the JWT
subandaudfields. - Configure the GitHub OIDC provider once per AWS account, scope an IAM role trust policy tightly, and set
permissions: id-token: writein the workflow. - Use separate roles for staging and production; bind production to GitHub Environments with required reviewers.
- Keep fork PR workflows off deploy roles; split read-only CI roles from deploy roles.
If your deployment pipeline authenticates to AWS with access keys stored in GitHub Secrets, you are carrying credentials that outlive every job. Those keys are often over-permissioned, rotated manually (if ever), and visible to every workflow that can read the secret. When a workflow is compromised through a poisoned dependency, a typosquatted action, or a fork PR exfiltration trick, the blast radius is your cloud account until someone notices.
OpenID Connect (OIDC) federation changes the credential shape. CI jobs receive short-lived tokens minted for a specific repository, branch, and environment, bound to an IAM role with least privilege. No static keys in GitHub. No shared deploy user in a password manager.
This guide is for platform and DevSecOps engineers running GitHub Actions against AWS. The same pattern generalizes to GCP Workload Identity and Azure Federated Credentials. You will configure an IAM OIDC identity provider, a trust-scoped IAM role, and a deploy workflow that assumes the role without secrets.
What do you need before you start?
- AWS account access with permission to create IAM OIDC providers and roles (IAM admin or equivalent).
- GitHub repository admin access to update workflows and delete legacy secrets.
- Familiarity with IAM trust policies and GitHub Actions
permissionsblocks. - A target deploy surface (for example S3, EKS, or Lambda) so you can scope the role permissions policy.
Why do long-lived CI keys fail in production?
Teams adopt access keys because they work on day one. The costs show up later:
| Symptom | Likely cause | What good looks like |
|---|---|---|
| Keys in five repos "for now" | Copy-paste deploy user | One role per repo or environment, OIDC-bound |
| Rotation ticket keeps slipping | Manual process, fear of outage | No keys to rotate; tokens expire in minutes |
Auditor asks who can assume PowerUser | Shared IAM user | CloudTrail shows AssumeRoleWithWebIdentity from a known repo |
| Fork PR suddenly wants AWS creds | pull_request workflows with secrets | OIDC sub claim scoped; no secrets on untrusted forks |
Static keys conflict with zero-trust thinking: the CI system is not a person and should not carry a permanent password. Implementing zero-trust in SaaS frames identity around continuous verification; OIDC applies that verification to the deploy path, not only the VPN you retired.
What does the naive GitHub Actions deploy workflow look like?
The first workflow most teams ship works in staging and becomes a liability in production. It stores an IAM user's access keys in GitHub Secrets and injects them into every deploy job:
Label: Anti-pattern for illustration (do not use in production).
name: Deploy to AWS
on:
push:
branches: [main]
pull_request:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync static assets
run: aws s3 sync ./dist s3://my-app-artifacts/production/ --deleteThree problems stack on top of each other:
| Problem | Why it hurts |
|---|---|
| Long-lived keys in Secrets | Every workflow with secret access inherits the same cloud identity until someone rotates keys |
| Shared IAM user across repos | One copied secret in five repositories means five blast radii tied to one AKIA user |
pull_request trigger with cloud creds | Fork PRs can exfiltrate secrets through malicious workflow changes unless you block untrusted runs |
The deploy step itself is fine. The credential model is the failure: permanent keys, broad scope, and no binding to a specific repository, branch, or environment. OIDC replaces that model without changing what the deploy step runs.
How does OIDC authentication work in GitHub Actions?
- GitHub Actions requests an OIDC JWT from GitHub (the
ACTIONS_ID_TOKEN_REQUEST_TOKENflow, handled byaws-actions/configure-aws-credentialswhen configured). - The AWS IAM OIDC identity provider trusts GitHub's issuer (
token.actions.githubusercontent.com). - Your IAM role trust policy checks JWT claims (
sub,aud,repository,ref) before allowingsts:AssumeRoleWithWebIdentity. - The workflow receives temporary
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, andAWS_SESSION_TOKENvalues valid for the job only.
The trust boundary is claim-based: only workflows matching your filters can assume the role. Most production incidents come from a too loose StringLike on sub, not from OIDC being inherently weak.
| Dimension | Long-lived access keys | OIDC-federated role |
|---|---|---|
| Lifetime | Until rotated or deleted | Minutes (STS session) |
| Storage | GitHub Secret, often duplicated | No cloud secret in GitHub |
| Scope | Whatever the IAM user has | Per role, per repo, branch, or environment |
| Audit | AccessKeyId in CloudTrail | AssumeRoleWithWebIdentity plus sub claim |
| Compromise recovery | Rotate all copies, hunt repos | Tighten trust policy; sessions expire |
| Setup cost | Low on day one | One-time IAM and workflow change |
How do you configure OIDC federation between GitHub Actions and AWS?
Add the GitHub OIDC provider in AWS
Run once per AWS account (console: IAM → Identity providers → Add provider → OpenID Connect):
| Field | Value |
|---|---|
| Provider URL | https://token.actions.githubusercontent.com |
| Audience | sts.amazonaws.com |
Label: Production-ready CLI example (replace thumbprint if AWS documentation requires an update).
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03fa02197ae0c8ae26c8b69dd77Tip
Thumbprints change rarely. If provider creation fails, verify the current thumbprint in the AWS IAM OIDC thumbprint guide.
Verify: aws iam list-open-id-connect-providers shows the GitHub issuer ARN.
Create an IAM role with a tight trust policy
Replace ORG, REPO, and the account ID. This example allows only the main branch of one repository:
Label: Simplified trust policy for illustration (replace placeholders before production use).
{
"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:ORG/REPO:ref:refs/heads/main"
}
}
}
]
}Attach a least-privilege permissions policy. Example for S3 deploy to one bucket prefix:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DeployArtifacts",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket", "s3:DeleteObject"],
"Resource": [
"arn:aws:s3:::my-app-artifacts",
"arn:aws:s3:::my-app-artifacts/production/*"
]
}
]
}For multiple environments, create deploy-staging and deploy-production roles. Scope sub with ref:refs/heads/main versus environment:production (see the next step).
Verify: In IAM → Roles → Trust relationships, the federated principal matches your repository slug exactly (case-sensitive).
Wire GitHub Actions to assume the role without secrets
Label: Production-ready workflow pattern (update role ARN, region, and S3 paths).
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy-production
aws-region: us-east-1
- name: Sync static assets
run: aws s3 sync ./dist s3://my-app-artifacts/production/ --deleteDelete legacy secrets AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. If anything still works after deletion, you found another copy in the org or a reusable workflow.
Verify: The workflow log shows a successful role assumption. In CloudTrail, filter EventName = AssumeRoleWithWebIdentity and confirm userIdentity.sessionContext.sessionIssuer points at your role.
Bind production deploys to GitHub Environments
For production, align human approval with claim scoping. Trust policy fragment:
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:ORG/REPO:environment:production"
}Workflow:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
# ... same configure-aws-credentials step with the production role ARNRequired reviewers on the production environment keep identity and process control aligned.
What errors appear when OIDC trust is misconfigured?
| Error or symptom | Fix |
|---|---|
Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity | Trust policy sub or aud mismatch; confirm repository name case and branch ref |
Credentials could not be loaded, please check your action inputs | Missing role-to-assume or id-token: write not set |
Access Denied on s3:PutObject after successful assume | Permissions policy on the role, not OIDC; trust worked |
Works on main but not on a feature branch | Trust policy only allows refs/heads/main; add a staging role or environment claim |
| Intermittent failures on self-hosted runners | Runner must reach sts.amazonaws.com and the GitHub OIDC endpoint; corporate SSL inspection can break JWT exchange |
Missing id-token: write: configure-aws-credentials cannot mint a JWT. Set the permission on the workflow or org default.
sub mismatch from typos: repo:MyOrg/my-app versus repo:myorg/My-App fails closed. Use CloudTrail AssumeRoleWithWebIdentity events to compare the sub claim with your trust policy. Do not print OIDC tokens in workflow logs.
Fork PR workflows: Do not grant OIDC-capable production roles to pull_request from forks unless you fully understand pull_request_target and claim spoofing. Prefer OIDC on push to protected branches or workflow_dispatch.
Over-broad StringLike: repo:ORG/* lets any repository in the org assume the role. Scope to one repository; split roles per service.
Same role for build and deploy: Unit test jobs should not assume deploy roles. Use ci-readonly (ECR pull, artifact read) versus deploy-production.
CloudTrail spot-check: Confirm userIdentity.sessionContext.webIdFederationData.federatedProvider shows GitHub. If you still see AKIA users from GitHub Actions IP ranges, a workflow still uses keys.
How do you deploy to EKS with an OIDC role?
OIDC roles often need eks:DescribeCluster plus RBAC mapped to the role:
{
"Effect": "Allow",
"Action": ["eks:DescribeCluster"],
"Resource": "arn:aws:eks:us-east-1:123456789012:cluster/prod"
}Map the role into the aws-auth ConfigMap or EKS access entries once. Avoid embedding cluster admin in the GitHub role; prefer namespace-scoped RBAC and service account impersonation where possible.
When should you not rely on OIDC for CI/CD?
OIDC is the right default for GitHub Actions to AWS, but it is not universal:
- CI platforms without OIDC: Older or self-hosted runners that cannot request a GitHub OIDC JWT need another pattern (instance roles on self-hosted infra, or short-lived credentials from a secrets manager with tight rotation).
- Org-wide shared deploy users: If you cannot split roles per repository or environment, OIDC still helps, but loose
subpatterns recreate the blast radius you are trying to remove. - Untrusted fork PRs needing cloud access: OIDC should not widen trust for fork workflows. Use read-only checks without cloud credentials, or isolated sandboxes.
- Break-glass human access: Emergency admin belongs in a separate human-assumable role, not in the CI deploy role. The CI role should not escalate to account admin.
- One-shot scripts outside Actions: Local deploy scripts still need their own credential model; OIDC does not replace human MFA for console work.
Framed for developers, OIDC is also deploy reliability: fewer rotation outages, clearer CloudTrail, and PR checks that do not need production keys. That is the same adoption logic as structured API errors and idempotent webhook handlers: make the secure path the easiest path.
How do you roll out OIDC across an organization?
- Ship a reference workflow and Terraform module (OIDC provider plus role template) in a platform repository.
- Require OIDC for new repositories via org policy; grandfather existing repos with a sunset date for
AKIAkeys. - Run a monthly IAM credential report and scan org secrets for
AWS_ACCESS_KEY_ID. - Pair migration with secret scanning on PRs so keys do not return while teams move.
How do you harden an OIDC deploy pipeline?
- No
AKIA*access keys in GitHub Secrets for CI - Trust policy uses
StringEqualsonaudand narrowStringLikeonsub - Separate IAM roles per environment; production uses GitHub Environment protection
- CloudTrail alerts on
AssumeRoleWithWebIdentityfrom unexpected repositories - Pin third-party actions to full commit SHAs for deploy workflows
-
permissions:defaults to minimum;id-token: writeonly where needed - Document break-glass human role separately; the CI role cannot escalate to admin
OIDC does not replace scanning, policy-as-code, or runtime detection. It removes a durable credential class that attackers hunt first. Combined with short-lived tokens, you shrink mean time to containment when a pipeline is abused: stolen session credentials expire with the job.
For authoritative setup steps, see GitHub: Configuring OpenID Connect in AWS, AWS IAM OIDC identity providers, and NIST SP 800-207 for zero-trust context.

