Git, CI/CD & GitOpsXCI · Least Privilege CI/CDEphemeralCredentials
Ephemeral per-job credentials — OIDC short-lived tokens
What you'll learn
- Distinguish long-lived credentials from short-lived per-job credentials
- Exchange an OIDC token for an STS session with bounded duration
- Use projected ServiceAccount tokens with explicit expiry
- Use dynamic secrets from Vault for database and API credentials
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Per-job credentials are issued at job start, scoped to the job, and expire at job end. The credential never outlives the job; an attacker who steals the credential inherits the remaining minutes, not the rotation cadence. Three implementations matter: OIDC tokens exchanged for cloud STS sessions, projected ServiceAccount tokens in Kubernetes, and dynamic secrets from Vault.
The lifetime as a security property
flowchart LR
subgraph BAD["Long-lived secret"]
S1["AWS access key\n90-day rotation"] --> W["Wide blast radius\nfor up to 90 days"]
end
subgraph GOOD["Per-job credential"]
S2["OIDC token at job start"] --> STS["STS session\n~1 hour"]
STS --> N["Narrow blast radius\nfor the job duration"]
end
The credential lifetime is itself a security property. A long-lived secret’s blast radius is its lifetime: an attacker who exfiltrates a 90-day key has 90 days of access. A per-job credential’s blast radius is the job duration: an attacker who exfiltrates the token at minute 30 of a 60-minute job has 30 minutes.
The three implementations of per-job credentials each have their own issuance path and lifetime ceiling:
- OIDC + STS. A CI job requests a JWT from the
forge (GitHub, GitLab); the cloud STS validates the
token against the trust policy and issues an STS
session of one to twelve hours (
DurationSeconds). The session credentials expire at session end. - Projected ServiceAccount token. A CI job mounts
a Kubernetes projected token with an explicit
expirationSeconds(default 3600, max 86400). The API server validates the token; the token expires at the bound time. - Vault dynamic secrets. A CI job calls the Vault API to generate a credential (PostgreSQL role, AWS access key, X.509 cert). Vault returns the credential and a lease ID; the credential expires at lease end.
OIDC and STS in practice
The exchange is two API calls: the forge issues the JWT
the job’s permissions block declares, and the cloud
STS validates the JWT against the trust policy. In
GitHub Actions:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::$AWS_ACCOUNT_ID:role/ci-tf-apply-prod
aws-region: eu-west-1
duration-seconds: 1800
The permissions: id-token: write is the trigger:
without it, the workflow cannot mint the OIDC token.
The configure-aws-credentials action calls
sts:AssumeRoleWithWebIdentity with the token and the
role ARN; AWS validates the token’s sub and aud
claims against the trust policy and returns an STS
session with Expiration set to start-time + 1800
seconds.
The session credentials are exported as
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
AWS_SESSION_TOKEN. They work for any AWS CLI or SDK
call; they fail at session end with ExpiredToken. The
failure is the audit working: the credential has
expired; the job needs a new one.
Projected ServiceAccount tokens in Kubernetes
The cluster-issued equivalent is the projected ServiceAccount token. The pipeline runner requests a token bound to the ServiceAccount with an explicit expiry; the API server mints the token and the ServiceAccount volume projection mounts it:
apiVersion: v1
kind: Pod
metadata:
name: ci-apply-runner
namespace: ci
spec:
serviceAccountName: ci-apply-prod-app
containers:
- name: kubectl
image: bitnami/kubectl
command: ["sleep", "infinity"]
For long-running workloads, a TokenRequest API call mints a fresh token per pod with an explicit expiry:
SA=ci-apply-prod-app
NS=ci
TTL=1800
kubectl create token "$SA" --namespace "$NS" --duration "${TTL}s"
The token is valid for 1800 seconds from the request moment. The runner uses it for kubectl calls; the token expires; the runner requests a new one if the job is still running. The structural difference from the long-lived Secret-based ServiceAccount token is that the projected token has a finite lifetime and is bound to the audience the runner declares.
Dynamic secrets from Vault
Vault generates per-lease credentials. A CI job authenticates to Vault with an OIDC token (or a Kubernetes ServiceAccount token via the Kubernetes auth method), requests a database credential for a specific role, and receives a username and password that work for the lease duration:
ROLE=ci-apply-readonly
TTL=900
VAULT_TOKEN=$(vault login -method=oidc -token-only)
CREDS=$(vault read -format=json "database/creds/$ROLE" | jq -r '.data | "PGUSER=\(.username) PGPASSWORD=\(.password)"')
eval "$CREDS"
The PostgreSQL role is created in Vault’s database
secrets engine; Vault runs the CREATE LOGIN and
GRANT statements for the role, returns the
credentials, and starts the lease timer. At lease end,
Vault runs the REVOKE statements; the credentials
stop working. The credential’s lifetime is the lease;
an attacker who exfiltrates the password has the
remaining lease duration, not a 90-day rotation
window.
Production discipline
- Issue credentials per job, not per pipeline. A credential that lives for one job has a one-job blast radius.
- Set duration to job length plus a small buffer.
duration-secondsfor STS,expirationSecondsfor ServiceAccount tokens, lease TTL for Vault. - Audit the trust policy and the permission policy together. The credential is safe only when both are tight.
- Revoke at job end if the credential outlives the job. Vault leases end automatically; STS sessions expire; projected tokens expire.
- Test the expiry path. A credential that fails silently when expired is harder to detect than one that fails loudly.
Cross-course references
- Part XLIII-04 (OIDC in AWS) covers the OIDC exchange and the trust policy.
- Part XLII-06 (The short-lived credential ideal) covers the discipline of moving from long-lived to short-lived credentials.
- Part XCI-02 (Scoped IAM roles) covers the permission policy that the STS session grants.
Quiz
Knowledge check · 4 questions
Q1. What is the security property of a per-job credential that distinguishes it from a long-lived secret?
Q2. Because OIDC-issued STS sessions are short-lived, the trust policy can be permissive (allow any workflow in the org) without expanding the blast radius.
Q3. Name three implementations of per-job credentials and the parameter that controls the lifetime of each.
Q4. Diagnose the long-lived credential exposure and prescribe the per-job migration.
Team T's GitHub Actions workflow stores an AWS access key (AKIA-prefixed) in the org-level AWS_ACCESS_KEY secret. The same key is used by 30 workflows across 12 repositories for terraform plan and apply. The key is rotated every 90 days; the last rotation was 14 days ago. An attacker exfiltrates the key from a fork-PR exploit on a low-trust repo.
Passing score: 75%. Answers are checked in this browser.