NovuSpark
All articles

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

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.

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

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.

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.

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 claims by the cloud provider itself.
  • Explicitly declare permissions: on every workflow — don't rely on GitHub's default token permissions, which are broader than most workflows actually need.
  • Never combine pull_request_target with checking out and executing a fork's own code using secrets that trigger implies access to — that combination is a well-known, real vulnerability pattern.

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.