open atlas
↑ Back to track
CI/CD pipelines CICD · 01 · 03

Secrets, environments and OIDC

CI credentials live as masked secrets, scoped by environment with protection rules. Forks get no secrets by default — and OIDC retires long-lived cloud keys for a short-lived signed token the cloud exchanges for temporary credentials.

CICD Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A contributor opens a friendly-looking pull request from their fork: it adds a test that prints a couple of environment variables “for debugging.” If your deploy keys had flowed into that build, the PR’s logs would now contain your production AWS credentials — exfiltrated to a fork you do not control, in a job triggered by a stranger. GitHub blocks exactly this by withholding secrets from fork-triggered workflows. The teams that get burned are the ones who reach for pull_request_target to “make secrets work on PRs” without understanding what they just re-enabled.

Secrets: encrypted, masked, and referenced by name

Before you paste an API key into a workflow YAML, ask yourself: if this repository became public tomorrow, what would an attacker have? That question drives every design decision in this section.

A secret is an encrypted value GitHub stores and injects into a job at runtime. You never paste a credential into the YAML; you reference it as ${{ secrets.NAME }}, and GitHub decrypts it into that one step’s environment. Secrets exist at three scopes that nest from broad to narrow: organization secrets (shared across repos, owner-controlled), repository secrets, and environment secrets (only available when a job targets that specific environment). Narrower scope means smaller blast radius — a production database password belongs on the production environment, not loose at the repo level where every workflow can read it.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Publish
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}   # decrypted into this step only
        run: npm publish

GitHub automatically masks registered secret values in logs: if a secret’s literal text appears in output, it is replaced with ***. This is a safety net, not a guarantee. Masking matches the exact string, so a secret that gets base64-encoded, URL-escaped, or split across lines sails straight through unredacted. The rule that follows is absolute: never echo a secret, never pass one as a CLI argument that gets logged, and never trust masking to save a set -x debug session. Treat a secret the way you treat a live wire.

Why this works

Masking is string-matching on stdout/stderr, so any transformation defeats it. echo $TOKEN is masked; echo $TOKEN | base64 is not — the encoded form never matched the registered value. The same gap explains why a secret accidentally written to an artifact, a coredump, or an error message that interpolates it can leak in plaintext. Mask is the last line of defense; the first is not putting the secret anywhere it can be observed.

The fork footgun: why PRs from forks get no secrets

Here is the security boundary that defines safe CI. When a workflow is triggered by a pull_request from a forked repository, GitHub does not pass your secrets to the runner (the lone exception is a read-scoped GITHUB_TOKEN). The reason is direct: a fork PR runs attacker-authored code — any stranger can propose a diff that adds a malicious step. If that step could read ${{ secrets.* }}, opening a PR would be enough to steal every credential in the repo. Withholding secrets makes fork CI safe to run automatically: the build can compile and test, but it cannot touch anything that grants real-world power.

The footgun is the “fix.” A maintainer notices that deploy or coverage-upload steps fail on fork PRs (no secrets) and switches the trigger to pull_request_target. That event runs in the context of the base repository — so it does get secrets and a write-scoped token. But it still checks out and can execute the PR’s untrusted code. You have re-opened the exact hole the default closed, now with write access. GitHub’s own security team named this class “pwn requests.” If you must enrich a fork PR with privileged work, split it: run untrusted code in the unprivileged pull_request job, and do the privileged step in a separate pull_request_target (or workflow_run) job that never checks out or runs the fork’s code.

Environments: named targets with guardrails

An environment is a named deploy target — staging, production — that you attach to a job with environment: production. Environments do two jobs at once: they scope secrets (a job only sees an environment’s secrets when it targets that environment) and they enforce protection rules before the job is allowed to start:

  • Required reviewers — the job pauses until a named human approves the deployment.
  • Wait timer — a forced delay (e.g. 10 minutes) before deploy, a window to abort a bad release.
  • Deployment branch restrictions — only main (or a tag pattern) may deploy to production, so a feature branch cannot ship.

Together these rules turn “who may deploy to production” from a policy that lives in a wiki into a mechanical constraint enforced before the job starts. Without them, every workflow that has the right secret can ship at any time from any branch.

jobs:
  release:
    runs-on: ubuntu-latest
    environment: production        # gates: reviewers, wait timer, branch rule
    steps:
      - run: ./deploy.sh
        env:
          API_KEY: ${{ secrets.API_KEY }}   # production-scoped secret

This is how you make “anyone can merge, not anyone can ship to prod” a mechanical fact rather than a policy people remember to follow.

OIDC: stop storing cloud keys at all

The senior upgrade is to delete the long-lived cloud credentials from your secrets entirely. A static AWS_SECRET_ACCESS_KEY sitting in your repo secrets is a permanent liability: it can leak, it must be rotated, and a single key often has broad blast radius. OpenID Connect (OIDC) replaces it with a short-lived, signed token. You configure your cloud provider once to trust GitHub’s OIDC issuer for specific workflows; then every job mints a fresh token, hands it to the cloud, and the cloud exchanges it for temporary credentials scoped to a role — valid only for that job, then gone. Nothing long-lived is stored, so nothing long-lived can leak, and there is nothing to rotate.

The workflow asks for the token by requesting the id-token: write permission:

permissions:
  id-token: write     # allow the job to request an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: us-east-1        # no AWS keys stored anywhere
      - run: aws s3 sync ./dist s3://my-bucket

The cloud-side trust policy is what makes this safe: it pins the token’s iss (GitHub’s issuer) and its sub claim to your specific repo, branch, or environment (e.g. repo:acme/app:ref:refs/heads/main). A fork or a different repo cannot assume the role, because their token’s subject will not match. The exchange itself:

DimensionStatic secret (AWS key in repo secrets)OIDC token exchange
What is storedLong-lived key + secret, encrypted in GitHubNothing — only a cloud-side trust policy
LifetimeUntil someone rotates it (often: never)Minted per job, expires in ~1 hour
RotationManual, error-prone, easy to forgetNone — every job gets a fresh token
Blast radius if leakedFull, until noticed and revokedMinutes; bound to one job’s claims
Setup costPaste a key — trivial, then permanent debtOne-time IAM/identity-provider config
Quiz

A contributor's fork PR adds a step that does `echo $AWS_SECRET_ACCESS_KEY | base64`. The workflow uses the default `pull_request` trigger. What happens?

Quiz

Why is configuring OIDC safer than storing a long-lived AWS key as a secret?

Pick the best fit

Your deploy job needs AWS credentials to push to production. Pick the approach a senior engineer ships.

Recall before you leave
  1. 01
    Why do fork pull-request workflows get no secrets by default, and why is switching to pull_request_target a footgun?
  2. 02
    Walk through the OIDC token exchange and explain what makes it safer than a stored cloud key.
Recap

Secrets are encrypted values GitHub injects at runtime and you reference as ${{ secrets.NAME }}, scoped organization-wide, per-repo, or — best — per-environment to shrink blast radius. GitHub masks registered secrets in logs, but masking is exact-string matching that any encoding or split defeats, so you never echo a secret or trust the net. The defining boundary: workflows triggered by a fork’s pull_request receive no secrets (only a read-scoped GITHUB_TOKEN), because they run untrusted code; the classic mistake is switching to pull_request_target to “restore” secrets, which re-grants them plus write access to attacker-authored code — the pwn-request hole. Environments add protection rules — required reviewers, wait timers, and deployment-branch restrictions — that gate which jobs and which branches may ship to production. The senior move is OIDC: instead of storing a long-lived cloud key, the job requests id-token: write, GitHub signs a short-lived token, and the cloud exchanges it for temporary role-scoped credentials bound by a sub-claim trust policy — no stored key, nothing to rotate, and a tiny window if anything is ever captured. Now when you review a workflow that uses pull_request_target, treat it as a red flag until you can confirm it never checks out or executes the PR’s code.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.