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.
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 publishGitHub 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 toproduction, 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 secretThis 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-bucketThe 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:
| Dimension | Static secret (AWS key in repo secrets) | OIDC token exchange |
|---|---|---|
| What is stored | Long-lived key + secret, encrypted in GitHub | Nothing — only a cloud-side trust policy |
| Lifetime | Until someone rotates it (often: never) | Minted per job, expires in ~1 hour |
| Rotation | Manual, error-prone, easy to forget | None — every job gets a fresh token |
| Blast radius if leaked | Full, until noticed and revoked | Minutes; bound to one job’s claims |
| Setup cost | Paste a key — trivial, then permanent debt | One-time IAM/identity-provider config |
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?
Why is configuring OIDC safer than storing a long-lived AWS key as a secret?
Your deploy job needs AWS credentials to push to production. Pick the approach a senior engineer ships.
- 01Why do fork pull-request workflows get no secrets by default, and why is switching to pull_request_target a footgun?
- 02Walk through the OIDC token exchange and explain what makes it safer than a stored cloud key.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.