NovuSpark
All articles
CI/CD & DevOpsMarch 13, 2026 · NovuSpark Team

Securing GitHub Actions: Secrets, OIDC, and Least Privilege

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-2

GitHub 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.

GitHub Actions jobGitHub OIDCissues signed tokenAWS STSverifies trust policytemp credentialsvalid ~1 hour,this run onlyno AWS_ACCESS_KEY_ID stored anywhere, ever
Fig. 1 — OIDC token exchange: GitHub issues a signed identity token, AWS verifies it and issues credentials that expire with the job
// 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 --delete

Notice 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: write

GitHub 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 below

A 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 code

This 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 20

CloudTrail 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 sub condition 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_target with 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.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.