Skip to content
LatchkeyLatchkey home

CI Security for GitHub Actions: Where the Real Exposure Is

CI holds your source, your secrets and a path to production, and it runs code from anyone who can open a pull request. Most of the exposure comes from four settings, none of which are on by default.

A CI pipeline is a machine that checks out your code, holds credentials for your registries and cloud accounts, and executes third-party code you did not write. Treated as plumbing it is the softest target in most engineering organisations.

The controls that matter are unglamorous and specific. They are not a scanner; they are four decisions about permissions, provenance, triggers and identity.

Scope the token down

The default GITHUB_TOKEN permissions are broader than nearly any job needs, and a compromised step inherits whatever the token can do. Set a read-only default at the workflow level and grant writes per job, so a step that only runs tests cannot push a commit.

.github/workflows/ci.yml
permissions:
  contents: read

jobs:
  release:
    permissions:
      contents: write      # only this job can write
      id-token: write      # and only this job can mint an OIDC token

Pin actions to a commit, not a tag

A tag is mutable. uses: some/action@v3 resolves to whatever that tag points at today, so trusting a tag is trusting the maintainer, everyone with write access to that repository, and anyone who compromises either of them, forever. Pinning to a full commit SHA fixes the code you audited.

.github/workflows/ci.yml
# Mutable: resolves to whatever v4 points at now
- uses: actions/checkout@v4

# Immutable: this exact tree, with the tag kept in a comment for readability
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

Understand pull_request_target

The pull_request trigger runs fork code without secrets, which is the safe default. pull_request_target runs in the context of the base repository with access to secrets, and it exists so that maintainers can label or comment on fork pull requests. Combining it with a checkout of the fork head executes untrusted code with your secrets in scope, which is the single most exploited GitHub Actions misconfiguration. If you use it, do not check out the pull request head in that job.

Use OIDC instead of long-lived cloud keys

A stored cloud access key is a credential with no expiry sitting in a place many people can reach. OIDC lets the workflow exchange a short-lived identity token for cloud credentials scoped to that run, so there is no static secret to leak, rotate or find in a log. It is the single largest reduction in blast radius available to most pipelines.

Runner isolation is part of the boundary

Where the job runs matters as much as what it is allowed to do. A persistent shared runner keeps a checkout of your source and whatever a previous job left behind, so one compromised job can reach the next. An ephemeral runner that executes a single job with single-use credentials and is then destroyed removes that pivot entirely. Latchkey runners are single-job machines on a private network, destroyed after each job.

Applying this to your pipeline

  • Measure before changing. Most CI optimisation targets the wrong step because the slow one is assumed rather than timed.
  • Cache what is expensive to produce and cheap to validate, and key the cache to the exact tool version.
  • Fail fast: run the cheapest checks that can reject a change first, so an expensive job never starts on code that cannot pass.
  • Prefer determinism over speed when they conflict. A fast pipeline nobody trusts gets re-run, which is slower than a slow one that is believed.

Frequently asked questions

What is the most common GitHub Actions security mistake?
Using pull_request_target and then checking out the pull request head. That combination runs code from a fork in a context that has access to your secrets, and it is the misconfiguration most often exploited in the wild. The plain pull_request trigger runs fork code without secrets and is the safe default.
Should I pin GitHub Actions to a commit SHA?
Yes for anything outside your own organisation. A tag is mutable, so trusting @v3 means trusting the maintainer and everyone with write access to that repository indefinitely. A full commit SHA pins the exact code you reviewed, and Dependabot can update the pins for you.
What permissions should GITHUB_TOKEN have?
The least each job needs. Set permissions: contents: read at workflow level so the default is read-only, then grant specific writes on the individual jobs that require them. A compromised step inherits the token, so a narrow token limits what a compromise can reach.
Why use OIDC instead of storing cloud credentials?
A stored key does not expire and cannot be scoped to a single run. OIDC exchanges a short-lived token for credentials issued to that specific workflow run, so there is no static secret to leak or rotate, and a leaked log line is not a standing key.

Related guides

References

Latchkey auto-heals failures like this one - detected, fixed, and retried without you. Start free → 30-day trial · No credit card