Secrets, PKI & CertificatesXIV · Platform IntegrationPlatformIntegration
CI/CD credentials: OIDC federation, log masking and runner compromise
What you'll learn
- Describe the OIDC exchange that replaces a stored cloud credential in a pipeline
- Pin a trust policy to the claims that actually constrain who can assume a role
- Predict which values log masking will and will not redact
- Distinguish the fork triggers that withhold secrets from the one that does not
Prerequisites
Practice
Verified against OpenSSL 3.5.x teaching target; 3.0+ minimum · OpenSSH 10.x teaching target; 8.2+ minimum for certificate workflows · OpenBao 2.6.x · Smallstep step-ca 0.30.x · Certbot / Pebble Certbot current release; Pebble 2.10.x ACME test server · Kubernetes (cross-course target) 1.36.x · PostgreSQL 17.x · 2026-08-26
A build system is a machine that runs other people’s code with your production credentials attached. Every control in this lesson exists to shrink one of two numbers: how long a credential the pipeline holds remains useful, and how many things can cause the pipeline to run. Getting the first number to the length of a single job is the biggest single improvement available, and it is a configuration change rather than a product purchase.
Replace the stored key with an exchange
OIDC federation removes the long-lived cloud credential entirely. You establish a trust relationship in the cloud provider that permits specific workflows to request access on behalf of a defined role. Each job run causes GitHub’s OIDC provider to generate a signed token; the job requests that token and presents it to the cloud provider; the provider validates the claims and returns a short-lived access token that is only valid for the duration of the job.
sequenceDiagram
participant J as Workflow job
participant G as GitHub OIDC provider
participant A as Cloud STS
J->>G: request token for this job
G-->>J: signed JWT with repository and ref claims
J->>A: present JWT, ask to assume the role
A->>A: validate issuer, aud and sub against trust policy
A-->>J: short-lived access token, job scoped
Nothing in that exchange is stored. There is no secret to rotate, no key to find in a log six months later, and no credential that outlives the job that used it. The workflow declares the permission to request a token, and the documentation is careful to note that granting it does not give the workflow permission to modify or write to any resources.
permissions:
id-token: write # required to request the JWT
contents: read # required for actions/checkout
The issuer is https://token.actions.githubusercontent.com. The
token carries iss, aud, sub, repository,
repository_owner, environment, job_workflow_ref, ref,
actor, workflow, run_id, runner_environment and the usual
exp, nbf and iat timestamps, plus any custom repository
properties under a prefixed name.
Pin the claims that actually constrain the request
A trust policy is only as good as the claims it tests. The aud
claim defaults to the URL of the repository owner, and the sub
claim carries the shape that identifies the specific context, for
example repo:octo-org/octo-repo:ref:refs/heads/demo-branch.
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/octo-branch"
}
}
}
A policy that tests only repository is the classic mistake. It
authorises every workflow in that repository, on any branch, tag or
pull request context, which means anybody who can get a workflow to
run there can assume the production role. Pin aud and sub
together, and where the deployment model supports it, add
environment, job_workflow_ref or ref so that only the reusable
workflow or the protected environment you intend can succeed.
Administrators can customise the sub shape through the REST API to
include additional claim keys, which is how an organisation
standardises on environment-scoped subjects.
Masking is a safety net, not a boundary
Runners redact the contents of configured secrets that are printed to workflow logs, and they also redact some values recognised as sensitive without being stored as secrets. The documentation states twice, in two separate reference pages, that this redaction is not guaranteed. Four properties explain why.
Redaction relies largely on finding an exact match for the specific secret value, so structured data can cause it to fail: a credential serialised into JSON or YAML, with quoting, escaping or line wrapping applied, is no longer the byte sequence the runner is looking for. Any transformation counts, and the documentation says so explicitly: if a secret is base64-encoded or URL-encoded, the new value must be registered as a secret too. Only secrets used within the current job are redacted, so a value that reaches a job by another route is invisible to the masker. And registration is not retroactive.
TOKEN="$(curl -sS -X POST "$TOKEN_ENDPOINT" | jq -r '.access_token')"
echo "::add-mask::$TOKEN"
The order of those two lines is the entire lesson. Registering a value with the masking command protects subsequent output only; a line already written cannot be unwritten. Each masked word separated by whitespace is replaced individually, and a value that has been masked can no longer be set as a step output. Derived credentials minted inside a job, which the runner has never seen, are exactly the values that need registering the moment they are produced.
Two capacity facts belong in the same design conversation. A single secret is limited to 48 KB, which rules out storing a large credential bundle. And an organisation may hold up to 1,000 secrets with 100 per repository and 100 per environment, but if more than 100 organisation secrets exist, only the first 100 in alphabetical order are exposed to a workflow, which produces a genuinely baffling missing-variable failure.
The fork trigger that hands over secrets
For a workflow triggered from a forked repository, secrets are not
passed to the runner, with the exception of the automatically
provisioned repository token, which is read-only in that context.
That is the protection almost everybody knows about, and it applies
to the pull_request event.
# ANTI-PATTERN. This runs fork-authored code with secret access.
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
pull_request_target is different in two ways that combine badly.
It runs in the context of the default branch of the base repository
rather than the merge commit, and its repository token is granted
read and write repository permission even when the run was triggered
from a public fork. Secrets are available. The workflow above then
explicitly checks out the contributor’s commit and executes its test
script, so an attacker submits a pull request whose test script
prints the deploy key wherever they like. GitHub’s own guidance is
blunt: workflows using these triggers must not check out untrusted
code, including from pull request forks, and running untrusted code
on this trigger may lead to cache poisoning and unintended access to
write privileges or secrets.
The correct pattern is to keep the untrusted build on
pull_request, where no secret is available, and to perform any
step that needs a credential in a separate, trusted workflow keyed
to a merged commit or a protected environment. Note also that a
repository setting exists to send write tokens and secrets to
workflows from pull requests; enabling it removes the protection
altogether, and it is worth auditing across an organisation.
Dependabot pull requests run as if from a fork, with a read-only
token and no secret access, which is why so many Dependabot
workflows appear to fail for no reason.
What a compromised runner holds
Assume for a moment that a step executes attacker-controlled code. The blast radius is exactly the set of credentials present in that job: every secret injected into its environment or referenced by its steps, the repository token with whatever permissions the workflow granted, the workspace contents, and any token already exchanged through OIDC.
The reason federation matters here is arithmetic rather than cryptography. A long-lived cloud key stolen from a runner keeps working until somebody notices and rotates it, which is typically measured in weeks. A federated token stolen from the same runner stops working when the job ends, and cannot be minted again without a run that satisfies the trust policy. That is the difference between an incident bounded by a job and an incident bounded by detection.
Production discipline
- Delete stored cloud keys once federation works. Leaving the old secret in place preserves the exact risk the change was made to remove.
- Test
audandsubin every trust policy. A policy keyed only to the repository authorises any branch, tag or pull request context in it. - Register derived values the moment they exist. Masking is not retroactive and cannot recognise an encoded form of a value it was never told about.
- Never check out fork code under
pull_request_target. Keep untrusted execution on the trigger that withholds secrets and move credentialed steps to a trusted workflow. - Rehearse a runner compromise. The exercise is listing every credential one job holds and confirming each one expires on its own rather than on your reaction time.
Cross-course references
- Git, CI/CD & GitOps for Infrastructure Engineers - Parts XLII (CI Secrets), XLIII (OIDC and Short-Lived Credentials) and XCV (Incident: Compromised Runner) cover the pipeline mechanics and the incident procedure that this lesson approaches purely from the credential lifetime side.
- Terraform for Production Sysadmins - Part XXII (CI/CD for Production Terraform) covers the pipeline that consumes the federated identity described here to run plans and applies.
- Observability for Production Sysadmins - Part LXXXII (Secrets and Sensitive Telemetry) covers keeping job output out of the log store, which is where an unmasked value stops being transient.
Quiz
Knowledge check · 4 questions
Q1. A cloud role trust policy for GitHub Actions OIDC tests only the repository claim. What does that authorise?
Q2. A secret that a workflow base64-encodes before printing is still redacted, because the runner derives the encoded forms from the original secret value.
Q3. Explain why pull_request_target is treated differently from pull_request in secret handling, and state the rule that follows.
Q4. Reconstruct how the credential left the pipeline and decide what must change before builds resume.
At 02:14 UTC an external researcher reports a working deployment credential for the example.com production account. The pipeline uses OIDC federation for deployments, but one legacy job still holds a stored cloud key for a reporting task. That job builds a JSON summary containing every environment variable it received and uploads it as a build artefact, and the repository has a workflow on pull_request_target that checks out the head commit of contributor pull requests. The masking never fired on the credential.
Passing score: 75%. Answers are checked in this browser.