Pipeline Security and Secrets
Why this matters
A CI/CD pipeline is one of the most privileged things in your whole
environment: it can build any code, push any image, and — in a push-based
setup — deploy to production. That makes it a prime target. A leaked
long-lived credential in a workflow, an over-permissive service connection,
or an unprotected production environment turns your delivery pipeline into
an attacker's delivery pipeline. This module pulls together the security
threads that have been previewed all track — OIDC federation (track 07 module
08), least privilege (module 01's permissions:), protected environments
(modules 00/01) — into a coherent posture: no long-lived secrets, minimum
privilege everywhere, and a human gate on production. It's the last standard
module before the capstone, and it's the bridge into track 11
(security-deep-dive), which adds scanning and policy gates to this exact
pipeline.
Concepts
OIDC federation vs. long-lived secrets (generalized)
Track 07 module 08 introduced this for Azure specifically; here's the general principle. A long-lived secret (a service-principal password, a registry token, a cloud access key) stored in GitHub is a standing credential: it exists until someone rotates it, it's valid from anywhere, and if it leaks (from a log, a compromised action, a misconfigured secret scope) an attacker can use it indefinitely. OIDC federation replaces it: the cloud provider is configured to trust GitHub's own token issuer for a specific repo/branch/environment, so at run time the workflow requests a short-lived token (minutes-long) that the provider verifies against the trust relationship. Nothing durable is stored in GitHub — there's no secret to leak and nothing to rotate, and the trust is scoped to exactly the workflow context you configured. This is the same pattern for Azure (federated credentials), AWS (IAM OIDC provider), and GCP (workload identity federation) — "no long-lived secret in the CI system" is the modern default, and everything in this module assumes it.
Scoping the trust: subject claims are the security boundary
OIDC is only as safe as the subject claim the cloud trusts. The federated
credential's subject (from track 07 module 08:
repo:<org>/<repo>:ref:refs/heads/main) is what limits which workflow runs
can assume the identity. Get it too broad — e.g. trusting any branch
(repo:<org>/<repo>:ref:refs/heads/*) or any environment — and a PR from a
fork or a throwaway branch could obtain production credentials. Scope it
tightly: to a specific branch (main), or better, to a specific
environment (repo:<org>/<repo>:environment:production), so only a run
that's been through the environment's protection rules (below) can get the
production identity. The subject claim is the actual security boundary, not
an incidental string — a typo or an over-broad pattern is a real
vulnerability, not just a misconfiguration.
Least privilege for the pipeline's identities
Two distinct privilege surfaces need minimizing:
- The cloud identity the workflow assumes (via OIDC). It should have the
narrowest roles that let it do its job, scoped to the narrowest
resources. Track 07 module 08 already made this concrete: the CI identity
needs
AcrPush(module 03) and a cluster role — but not subscriptionOwner. Scope roles to the specific ACR and cluster resource IDs, not the whole resource group or subscription. In GitOps (module 05), the pipeline needs even less — often only Git push access to the config repo, and no cluster credentials at all, because ArgoCD does the deploying. GitOps is a least-privilege win in itself. - The
GITHUB_TOKEN(module 01'spermissions:). Default tocontents: readand add only what each job needs (id-token: writefor OIDC,packages: writeto push to GHCR). An over-broad token is a blast-radius risk if any step runs untrusted code.
The principle mirrors track 07 module 07's Kubernetes RBAC: grant the
specific verb on the specific resource, nothing more. A "service connection"
(the pipeline's identity into the cloud) with Contributor on the whole
subscription is the pipeline-security equivalent of a cluster-admin
ServiceAccount — convenient and dangerous.
Protecting main and production: required reviewers and environments
Two enforcement layers gate what reaches production:
- Branch protection on
main(module 02): required status checks + required reviews mean code can't merge without passing tests and human review. This protects what enters the deployable state. - Protected environments (module 01): a GitHub Environment named
productionwith a required reviewer rule pauses any job targeting it until a designated human approves — the manual gate that turns continuous deployment into continuous delivery (module 00). Combined with an OIDC subject scoped toenvironment:production, this means the production credential is only obtainable by a run that a human has approved. You can also add a wait timer (a cooling-off delay) and restrict which branches can deploy to the environment.
Together: broken/unreviewed code can't merge, and even merged code can't reach production without a human clicking approve and the run satisfying the environment's scoped trust.
Secret hygiene when you do need secrets
OIDC removes cloud credentials, but some secrets remain unavoidable (a third-party API key, a database password). Handle them well:
- Scope them to environments, not the whole repo (module 01) — a
stagingsecret shouldn't be readable by aproductionjob or vice versa, and neither by an arbitrary PR run. - Never echo them — GitHub masks known secret values in logs, but transformed secrets (base64'd, concatenated) can slip through masking. Don't print secrets, even "temporarily to debug."
- Beware
pull_request_targetand untrusted PRs — workflows triggered by PRs from forks must not expose secrets to attacker-controlled code. The defaultpull_requesttrigger deliberately withholds secrets from fork PRs; don't defeat that. - In GitOps, never commit raw Secrets to Git (module 05) — base64 is encoding, not encryption. Use sealed-secrets, SOPS, or an external secrets operator (track 11/16).
Auditing and rotation: assume something will leak
Defense in depth assumes a control will fail. Keep the pipeline auditable and
recoverable: OIDC's short-lived tokens mean a leaked token expires in minutes
(vs. a standing secret valid forever); federated-credential and role
assignments are listable (az ad app federated-credential list,
az role assignment list) so you can review what the pipeline can do;
GitHub's audit log records secret access and environment approvals; and any
long-lived secret you couldn't avoid needs a rotation schedule. The
question to ask of any pipeline: "if this credential leaked right now, how
long is it valid, how much can it touch, and would we know?" OIDC + least
privilege + audit makes all three answers small.
Command reference
The Azure/GitHub commands for federated identity, scoped roles, and environment protection (building on track 07 module 08).
| Command | What it does | Notes |
|---|---|---|
az ad app create --display-name <n> |
Creates the AD app to federate with GitHub OIDC | Track 07 module 08 |
az ad app federated-credential create --id <app> --parameters <json> |
Adds a trusted OIDC subject | The subject is the security boundary |
az ad app federated-credential list --id <app> -o table |
Lists trusted subjects | Audit what can assume the identity |
subject: repo:<org>/<repo>:environment:production |
Scopes trust to the production environment |
Tighter than a branch subject |
subject: repo:<org>/<repo>:ref:refs/heads/main |
Scopes trust to the main branch |
Track 07 module 08's pattern |
az role assignment create --assignee <app> --role AcrPush --scope <acr-id> |
Grants push on a specific ACR only | Least privilege: scope to the resource, not the sub |
az role assignment list --assignee <app> -o table |
Lists what the identity can do | Audit for over-privilege |
az role assignment delete --assignee <app> --role <r> --scope <s> |
Removes an over-broad grant | Tightening privilege |
permissions: { id-token: write, contents: read } |
Scopes the GITHUB_TOKEN (module 01) |
Minimum for OIDC + checkout |
environment: production (job key) |
Targets a protected environment | Triggers required-reviewer gate |
gh api repos/:owner/:repo/environments/production -X PUT -f 'reviewers[...]' |
Configures environment protection via API | Or set required reviewers in the UI |
gh secret set NAME --env production |
Sets an environment-scoped secret | Not readable outside that environment |
gh api repos/:owner/:repo/branches/main/protection |
Reads branch-protection config | Confirm required checks/reviews (module 02) |
gh api /orgs/<org>/audit-log |
Reads the org audit log | Review secret access / approvals (org plans) |
Hands-on exercises
Use your GitHub repo, AKS cluster, and ACR from earlier modules. Several exercises revisit track 07 module 08's OIDC setup — that's intentional; this module hardens and scopes it.
Audit your current pipeline's privilege. For the OIDC identity you created in track 07 module 08, run
az role assignment list --assignee <client-id> -o tableandaz ad app federated-credential list --id <app-id> -o table. Write down: what roles does it have, at what scope (resource vs. resource group vs. subscription), and what branches/environments does it trust? Flag anything broader than "push to this ACR + act on this cluster frommain."Tighten an over-broad role. If your CI identity has
Contributorat the resource-group or subscription scope (a common quick-start shortcut), replace it with the minimal set scoped to specific resources:AcrPushon the ACR's resource ID and the cluster-user role on the cluster's resource ID (track 07 module 08). Delete the broad assignment (az role assignment delete) and confirm the pipeline still deploys — least privilege verified by the pipeline still working with less.Scope the federated credential to an environment, not a branch. Add a federated credential with subject
repo:<org>/<repo>:environment:production. Confirm it's listed. This sets up exercise 5, where only an approvedproduction-environment run can assume the identity.Minimize the
GITHUB_TOKEN. Audit each job'spermissions:block. Set the workflow default tocontents: readand add per-job only what's needed (id-token: writeon the deploy job). Push and confirm everything still runs. Try removingid-token: writeand watch OIDC login fail (track 07 module 08's known error) — proof the permission is load-bearing and that you granted the minimum, not less.Protect the production environment with a required reviewer. Create a GitHub Environment
productionwith a required reviewer (yourself or a teammate). Addenvironment: productionto your deploy job. Push a change and watch the deploy job pause for approval; approve it and watch it proceed. This is the human gate that makes it continuous delivery (module 00). Combined with exercise 3's environment-scoped subject, the production credential is now only reachable through an approved run.Prove secrets don't leak to fork PRs. Add a repo secret and a workflow step that (harmlessly) checks whether the secret is set. Open a PR from a fork (or simulate the
pull_requesttrigger's fork behavior) and confirm the secret is empty in that run — GitHub withholds secrets from untrusted fork PRs by default. Note why defeating this (e.g. viapull_request_targetrunning fork code with secrets) would be dangerous.Show the OIDC blast-radius advantage concretely. Contrast two scenarios in writing: (a) a long-lived SP secret (
AZURE_CREDENTIALS) leaks from a log; (b) an OIDC token leaks from a log. For each, state how long the credential is valid and what an attacker can do. Tie the difference back to why OIDC is the default (short-lived, scoped, nothing stored). If you still have anAZURE_CREDENTIALSsecret from an old setup, delete it.Verify the GitOps least-privilege win. For your ArgoCD setup (module 05), confirm the CI pipeline holds no cluster credentials at all — only Git push access to the config repo. Compare against track 07 module 08's push pipeline, which needed a cluster role. Write two sentences on why moving the cluster credential from "the CI system" to "only inside the cluster (ArgoCD)" shrinks the attack surface.
Diagnose and fix: a pipeline secret misconfigured. You'll reproduce the most common secrets failure. The deploy job fails authenticating, and the logs show an empty/blank credential where a secret should be. Reproduce one of: (a) the secret is defined at the repository level but the job declares
environment: productionand the secret was set only for a different environment (so the job reads an empty value — the environment-scoping rule from module 01); (b) a typo in the secret name (AZURE_CLIENT_IDvsAZURE_CLIENTID) yields an empty string; or (c) the OIDC subject doesn't match the run's context (branch/environment), so the token exchange is rejected by Azure (track 07 module 08). Investigate: check which environment the job targets and where the secret is scoped (gh secret list --env production), check the exact secret name against the workflow, and check the federated-credential subject against the run's actual ref/environment. Fix the real cause and confirm a green, authenticated deploy. Write one sentence on why "the secret is right there in settings" doesn't mean "this job can read it."
Independent challenge
No commands given — assemble it from track 07 module 07 (RBAC/least
privilege), track 07 module 08 (OIDC), module 01 (permissions:/
environments), module 02 (branch protection), and module 05 (GitOps's
credential shift). Harden an existing deploy pipeline so that no long-lived
cloud credential exists anywhere, privilege is minimal and scoped, and
production is reachable only through a reviewed, approved, tightly-scoped
path — then prove each property. Convert any stored cloud secret to OIDC
federation and delete the old secret; scope the cloud identity's roles to
specific resource IDs (not the resource group or subscription) and confirm
the pipeline still works with the reduced grant; scope the federated trust to
the production environment rather than a branch; set every job's
GITHUB_TOKEN permissions to the documented minimum; require passing checks
and human review to merge to main; and put a required reviewer on the
production environment so a deploy pauses for approval. Then demonstrate the
security posture end to end: show that a fork PR gets no secrets, that a run
not targeting the approved production environment cannot obtain the production
credential, and that if the deploy credential leaked it would be valid for
minutes and touch only one ACR and one cluster. Conclude by naming which
control would catch each of two threats — a leaked credential and a malicious
unreviewed commit — and note which of these track 11 will extend with image
scanning and policy gates.
Stuck? One hint
Attack it as "what could go wrong, and what control stops it," not as a
checklist of settings. For each threat, name the single control: a leaked
credential → OIDC (short-lived + scoped, so the leak is nearly worthless) plus
resource-scoped roles (small blast radius); a malicious unreviewed commit →
branch protection with required reviews (can't merge) plus a protected
production environment (can't deploy without approval); a fork trying to
steal secrets → the default pull_request trigger withholding secrets (don't
switch to pull_request_target with fork code). The subtle, high-value move
is scoping the OIDC subject to environment:production instead of a branch:
that ties the production credential to the environment's approval gate, so
"get production access" now requires "pass a human review" — the two controls
reinforce instead of sitting independently.
Common mistakes & troubleshooting
- Keeping a long-lived SP secret when OIDC is available. A stored
credential is valid forever from anywhere and must be rotated; OIDC tokens
are short-lived and scoped. Delete
AZURE_CREDENTIALS-style secrets once OIDC works (track 07 module 08). - Over-broad OIDC subject. Trusting all branches/forks, or a wildcard subject, lets untrusted runs assume the identity. Scope to a specific branch or, better, a specific environment.
- Roles scoped to the subscription/resource group.
Contributoron the subscription is the pipeline equivalent ofcluster-admin. Scope roles to the specific ACR and cluster resource IDs. - Default (broad)
GITHUB_TOKENpermissions. Leaving write-all permissions is a blast-radius risk. Default tocontents: read; add only what each job needs. - Environment-scoped secret read by the wrong job. A secret set for
stagingis empty in aproductionjob (and vice versa), and repository secrets aren't the same as environment secrets. "It's in settings" ≠ "this job can read it" — match the scope. - Echoing secrets to debug. Transformed secrets can evade log masking. Never print secrets; use them directly.
pull_request_targetrunning fork code with secrets. This deliberately dangerous pattern exposes secrets to attacker-controlled code. Avoid it for untrusted PRs; the defaultpull_requestwithholds secrets for a reason.- Committing raw Secrets to a GitOps repo. base64 isn't encryption (module 05). Use sealed-secrets/SOPS/external secrets.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Why is a leaked OIDC token far less dangerous than a leaked long-lived service-principal secret? Address validity duration, scope, and rotation.
- What is the OIDC "subject" claim, and why is scoping it to
environment:productionstronger than scoping it to a branch? - What two distinct privilege surfaces does pipeline least-privilege apply to, and give one concrete over-privilege example of each.
- How do branch protection (module 02) and protected environments (module
- each gate what reaches production, and how do they differ?
- A deploy job reads an empty value for a secret that's clearly set in repo settings. Give two distinct causes and how you'd confirm each.
- Why does adopting GitOps (module 05) reduce the pipeline's privilege even before you harden anything?
- Why does the default
pull_requesttrigger withhold secrets from fork PRs, and what's the danger ofpull_request_target? - For any pipeline credential, what three questions summarize its security posture, and how do OIDC + scoped roles + audit make all three answers small?
Show answers
- An OIDC token is short-lived (minutes), so a leak expires almost immediately; it's scoped to a specific repo/branch/environment, so it can't be used from elsewhere; and there's nothing stored to rotate. A long-lived SP secret is valid until manually rotated, usable from anywhere, and a standing thing that must be tracked and rotated — a leak is exploitable indefinitely.
- The subject is the claim the cloud trusts to decide which workflow runs may
assume the identity. Scoping to
environment:production(vs. a branch) ties the credential to the environment's protection rules — so only a run that passed the required-reviewer approval can obtain the production credential, whereas a branch subject grants it to any run on that branch with no approval step. - (a) The cloud identity the workflow assumes — over-privilege example:
Contributoron the whole subscription instead ofAcrPushon one ACR + a cluster role. (b) TheGITHUB_TOKEN— over-privilege example: default write-all permissions instead ofcontents: read+ only what's needed. - Branch protection gates what can merge into
main(required checks + reviews) — protecting the deployable state. Protected environments gate what can deploy (required reviewer pauses the deploy job for approval) — protecting the act of releasing. One guards entry tomain; the other guards the release frommainto production. - (a) The secret is scoped to a different environment (or is a repo secret
while the job targets an environment) — confirm with
gh secret list --env <name>and check the job'senvironment:. (b) A typo in the secret name yields an empty string — confirm by diffing the exact name in settings against the workflow reference. (Also: OIDC subject mismatch rejects the token — confirm the federated-credential subject against the run's ref/env.) - In GitOps the deploy is done by ArgoCD inside the cluster, so the CI pipeline no longer needs cluster credentials at all — often only Git push access to the config repo. The most dangerous credential (cluster access) moves out of the externally-reachable CI system entirely.
- Fork PRs contain untrusted, attacker-controllable code; running it with
access to secrets would let an attacker exfiltrate them.
pull_requestwithholds secrets from fork runs to prevent this.pull_request_targetruns in the context of the base repo with secrets — combining it with checking out and running fork code is a known secret-exfiltration hole. - "If this credential leaked: how long is it valid, how much can it touch, and would we know?" OIDC makes validity short (minutes); scoped, resource- level roles make the blast radius small (one ACR/one cluster); and audit logs + listable role/credential assignments mean you'd know and could review — so all three answers are minimized.
Next
08-capstone-project — put the entire track together: PR-gated tests, a SHA-tagged image built and pushed to ACR on merge, and ArgoCD syncing a Git-committed manifest to AKS with a progressive rollout.