This is the fourth post in our GitHub and GitHub Actions series, building on Actions basics and reusable workflows.
We used long-lived AWS access keys stored as GitHub Secrets in our own real deploy pipeline, documented in our CI/CD case study — and we scoped that credential's IAM permissions as narrowly as possible specifically because of the risk this post covers directly: a long-lived credential sitting in any secret store is a standing liability for as long as it exists, however carefully it's scoped. Because CI/CD credentials are one of the highest-value targets in most real organizations' infrastructure, it's worth covering this properly, not just at a surface level.
Secrets: the baseline, and its limits
- 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: eu-west-2GitHub Secrets are encrypted at rest and only decrypted into a job's ephemeral environment at run time — a genuinely reasonable baseline, not a weak mechanism. Their real limitation isn't how they're stored; it's what they store: a long-lived credential that works identically whether it's used by your legitimate workflow or by an attacker who obtained it. A compromised dependency, a malicious pull request from a fork, or a subtly misconfigured workflow can all potentially exfiltrate a secret's value during a run — after which that credential remains valid until someone notices and manually rotates it.
OpenID Connect (OIDC): removing the standing credential entirely
OIDC federation lets GitHub Actions request short-lived, automatically-expiring cloud credentials directly from AWS (or Azure, or GCP), for the duration of a single job — with no long-lived access key stored anywhere, ever.
// AWS IAM trust policy for the OIDC provider
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::008971632408:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/novuspark-website:ref:refs/heads/master"
}
}
}
]
}# .github/workflows/deploy.yml
permissions:
id-token: write # required for OIDC — without this, the token request fails
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::008971632408:role/github-actions-deploy
aws-region: eu-west-2
- run: aws s3 sync ./out s3://novuspark-portal --deleteNotice there are no AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY secrets anywhere in this version at all. GitHub issues a short-lived, cryptographically signed OIDC token, unique to this specific run; AWS's trust policy verifies that token's claims (which specific repository, which specific branch) before issuing temporary credentials, valid only for the duration of that one job. There is no long-lived credential to leak, because none exists.
The StringLike condition on sub in the trust policy is doing real security work, not just filling in a template value — it restricts which repository, and which specific branch, is even allowed to assume this role at all. A pull request from an unrelated fork, or a push to an unrelated branch, gets rejected by AWS itself, independent of anything the workflow file says.
Understanding the sub claim in more depth
The sub (subject) claim is worth understanding precisely, because a claim written too loosely quietly undermines the whole point of scoping it:
repo:your-org/novuspark-website:ref:refs/heads/master
repo:your-org/novuspark-website:pull_request
repo:your-org/novuspark-website:environment:production
Each of these represents a genuinely different, narrower trust boundary. The first restricts to pushes on one specific branch — appropriate for a deploy role that should only ever run from master. The second matches any pull request against the repository, from any branch — appropriate for a job that only needs to run tests, never appropriate for a role capable of deploying to production, since it would let any PR (including, with the fork caveat below, potentially an untrusted one) assume that role. The third restricts to jobs running against a specific GitHub Environment — often the most precise option, since it composes naturally with the environment-scoped approval gates covered in the final post in this series.
{
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/novuspark-website:environment:production"
}
}A trust policy written as repo:your-org/* (matching every repository in an organization) or with no sub condition at all technically still uses OIDC's short-lived-credential mechanism, but discards the actual access-scoping benefit — any workflow, in any repository the wildcard matches, could assume that role. The security value of OIDC comes from both halves together: no standing credential, and a tightly scoped trust condition on exactly which workflow runs are allowed to use it.
Least privilege, applied to the token itself
permissions:
contents: read
id-token: writeGitHub Actions grants the workflow's own GITHUB_TOKEN broad permissions by default unless a workflow explicitly restricts them. Declaring permissions: explicitly — contents: read here, nothing broader — means that even if this specific workflow were somehow compromised, the token it's actually using has no ability to, for example, push new code, modify repository settings, or approve pull requests. This is the exact same principle behind the narrowly-scoped IAM user we built for our own deploy pipeline: scope every credential in the pipeline, not just the cloud ones, to only what that specific job genuinely needs.
This is worth setting at the workflow level as a default, and overriding per-job only where a specific job genuinely needs more:
permissions:
contents: read # workflow-wide default: read-only
jobs:
deploy:
permissions:
contents: read
id-token: write # this job specifically needs to assume an AWS role
runs-on: ubuntu-latest
# ...
comment-on-pr:
permissions:
contents: read
pull-requests: write # this job specifically needs to post a PR comment
runs-on: ubuntu-latest
# ...Scoping permissions per job, rather than granting the broadest set any job in the workflow needs to the entire workflow, means a compromise or bug in one job can't leverage permissions that were only ever actually needed by a different job in the same file.
Pull requests from forks: a distinct, sharper risk
on:
pull_request_target: # use with real caution — see belowA workflow triggered by pull_request from a fork runs with no access to repository secrets by default — a deliberate GitHub safeguard, since a fork's pull request could otherwise contain arbitrary, untrusted code that a workflow might execute with your repository's own credentials. pull_request_target removes that safeguard specifically because it's sometimes genuinely needed (commenting on a PR from a fork requires it, for instance) — but it must never be combined with checking out and running the fork's own code using secrets that trigger grants access to. This is one of the most common real vulnerability patterns in public open-source repositories' CI configuration, and worth checking deliberately in any workflow using pull_request_target.
A safer pattern for the genuine use case (commenting on or labeling a fork's PR) separates the untrusted build artifact from the trusted, secret-bearing action that acts on it:
# workflow-a.yml — runs on pull_request (no secrets, safe to run fork code)
name: Build PR artifact
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# workflow-b.yml — runs on workflow_run, has secrets, never checks out fork code
name: Comment on PR
on:
workflow_run:
workflows: ["Build PR artifact"]
types: [completed]
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
// download the artifact from workflow-a and post a comment,
// without ever checking out or executing the fork's own codeThis two-workflow split is the standard mitigation: the workflow that runs untrusted fork code never has secret access, and the workflow that has secret access never executes anything the fork's pull request actually contributed.
Auditing what actually ran
Beyond preventing a bad configuration up front, it's worth being able to answer "what has actually run with this role's credentials, historically" after the fact:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=github-actions-deploy \
--max-results 20CloudTrail records every API call made using a given role's assumed credentials, including the specific OIDC-derived session — genuinely useful both for routine review and for incident investigation, confirming exactly what a CI role has actually done, not just what its policy theoretically permits.
What to actually remember from this post
- A long-lived credential in GitHub Secrets is a standing liability for as long as it exists — well-encrypted at rest, but still fundamentally reusable by anyone who obtains it.
- OIDC federation replaces a standing credential with a short-lived one, scoped to a single run, verified against specific repository and branch (or environment) claims by the cloud provider itself.
- A loosely-scoped
subcondition undermines OIDC's actual benefit — pin it to a specific branch or environment, not a wildcard covering every repository or every event type. - Explicitly declare
permissions:on every workflow, scoped per job — don't rely on GitHub's default token permissions, and don't grant an entire workflow permissions only one job actually needs. - Never combine
pull_request_targetwith checking out and executing a fork's own code using secrets that trigger implies access to — split untrusted build steps from trusted, secret-bearing steps across two workflows instead. - Audit role usage via CloudTrail (or your cloud provider's equivalent) — confirm what a CI credential has actually done, not just what its policy permits.
Next in the series: GitHub Actions for Multi-Environment Deployments, the final post — covering how the same workflow safely deploys to staging and production differently.
