Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXCI · Least Privilege CI/CDEphemeralCredentials

Ephemeral per-job credentials — OIDC short-lived tokens

Advanced⏱ ~26 mingit

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

Not yet marked complete on this device.

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

  1. Issue credentials per job, not per pipeline. A credential that lives for one job has a one-job blast radius.
  2. Set duration to job length plus a small buffer. duration-seconds for STS, expirationSeconds for ServiceAccount tokens, lease TTL for Vault.
  3. Audit the trust policy and the permission policy together. The credential is safe only when both are tight.
  4. Revoke at job end if the credential outlives the job. Vault leases end automatically; STS sessions expire; projected tokens expire.
  5. 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

  1. Q1. What is the security property of a per-job credential that distinguishes it from a long-lived secret?

  2. 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.

  3. Q3. Name three implementations of per-job credentials and the parameter that controls the lifetime of each.

  4. 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.