Introduction – The All‑Too‑Common Shortcut
Many teams accelerate delivery by dropping static AWS access keys straight into their
.github/workflows/*.yml files. The practice looks harmless at first glance –
the pipeline can push Docker images, update CloudFormation stacks, and spin up test
environments without extra configuration. However, hard‑coded credentials create a
permanent backdoor that can survive repository migrations, forking, and even
accidental public exposure.
The Hidden Risks of Hard‑Coded Secrets
Credential leakage. A single commit pushed to a public fork can reveal privileged keys to anyone scanning the internet. Attackers can then use those keys to enumerate S3 buckets, launch EC2 instances, or exfiltrate sensitive data.
Privilege creep. When the same key is reused across dozens of repositories, revoking it for a single compromised project becomes impossible without breaking every other pipeline that depends on it.
Lack of auditability. AWS CloudTrail records actions taken with the key, but it cannot easily attribute them to a particular CI job or developer because the same IAM user is used everywhere.
# Example of a vulnerable GitHub Actions workflow
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS
env:
AWS_ACCESS_KEY_ID: AKIA**** # ← hard‑coded!
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCY****
run: |
aws s3 sync ./static s3://my‑prod‑bucket --delete
The snippet above demonstrates the pattern that should be avoided. Below we replace this approach with a zero‑secret, OIDC‑based workflow that leverages GitHub’s built‑in identity federation.
Prerequisites – Setting Up the Trust Relationship
1. An AWS account with administrative rights to create an IAM role.
2. A GitHub repository where you control the workflow files.
3. The aws-cli version 2.x installed in the runner (default on
ubuntu-latest images).
# Create an IAM role that trusts GitHub OIDC
aws iam create-role \
--role-name GitHubActionsDeploy \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:${GITHUB_OWNER}/${GITHUB_REPO}:ref:refs/heads/main"
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}]
}'
The policy above tells AWS to trust tokens issued by GitHub’s OIDC provider, but only
when the token originates from the main branch of the specified repository.
Next, attach the permissions the CI job needs.
# Attach a minimal policy – e.g., S3 sync and CloudFormation update
aws iam put-role-policy \
--role-name GitHubActionsDeploy \
--policy-name DeployPolicy \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject",
"cloudformation:UpdateStack",
"cloudformation:CreateChangeSet",
"cloudformation:ExecuteChangeSet"
],
"Resource": "*"
}
]
}'
Secure Workflow – No Secrets, Only Identity
With the role in place, the GitHub Actions workflow can request a short‑lived AWS
credential by invoking the aws-actions/configure-aws-credentials action.
The action automatically exchanges the OIDC token for a set of temporary credentials.
# .github/workflows/deploy.yml
name: Secure Deploy
on:
push:
branches: [ main ]
permissions:
id-token: write # Required for OIDC token exchange
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsDeploy
aws-region: us-east-1
- name: Sync static assets
run: |
aws s3 sync ./static s3://my-prod-bucket --delete
- name: Deploy CloudFormation stack
run: |
aws cloudformation deploy \
--template-file infra/template.yml \
--stack-name my-prod-stack \
--parameter-overrides Env=prod
Notice that the workflow never references AWS_ACCESS_KEY_ID or
AWS_SECRET_ACCESS_KEY. Instead, it relies on the OIDC token that GitHub
injects into the runner. The token is short‑lived (typically a few minutes) and scoped
exactly to the role we defined.
Why This Approach Eliminates the Silent Liability
Zero static secrets. There is nothing to accidentally commit, rotate, or revoke across multiple repositories.
Fine‑grained, per‑branch access. The trust condition can be tightened to only allow deployments from a protected branch or from a specific GitHub environment.
Automatic credential rotation. Each run receives a fresh set of temporary credentials that automatically expire, removing the attack window.
# Verify the temporary credentials (optional debugging step)
aws sts get-caller-identity
# Expected output:
# {
# "Account": "123456789012",
# "UserId": "AROAEXAMPLEID:GitHubActionsDeploy",
# "Arn": "arn:aws:sts::123456789012:assumed-role/GitHubActionsDeploy/GitHubActionsDeploy"
# }
By inspecting the ARN you can confirm that the identity is an assumed role, not a long‑lived IAM user.
Security and Best Practices
- Least privilege. Grant only the actions your pipeline truly needs.
- Branch protection. Combine OIDC with GitHub branch protection rules to ensure only reviewed code can trigger deployments.
- Audit logs. Enable CloudTrail data events for S3 and CloudFormation to trace every deployment back to the originating commit SHA.
- Rotate the role. Periodically rotate the role name or replace the policy to force a re‑evaluation of trust relationships.