CI/CD Lesson 23 – Secrets Management | Dataplexa
Section III · Lesson 23

Secrets Management in CI/CD

In this lesson

Secrets vs Config GitHub Secrets External Secrets Managers Secret Rotation OIDC & Keyless Auth

Secrets management is the discipline of storing, distributing, rotating, and auditing sensitive credentials — API keys, database passwords, deployment tokens, TLS certificates — in a way that makes them available to the pipeline at runtime without ever exposing them in source code, log files, or build artifacts. A pipeline that cannot access credentials cannot deploy. A pipeline that handles credentials carelessly becomes an attack surface. Secrets management is the practice that threads this needle: credentials are available exactly where they are needed, and nowhere else.

Secrets vs Configuration — A Critical Distinction

Not all per-environment values are secrets. A secret is a value whose exposure would cause harm — a private key, an authentication token, a database password. A configuration value is a value that varies per environment but carries no inherent risk if seen — a database hostname, a feature flag state, a log level. Treating these two categories identically — storing everything as a secret — produces an unmanageable secret store and obscures which values actually need protection. Treating configuration as secrets adds management overhead for no security benefit.

Secrets vs Configuration — Examples and Storage Approach

Secrets — store encrypted, never log
Configuration — can be stored in env vars or files
Database passwords
API keys and tokens
TLS private keys
OAuth client secrets
Cloud provider credentials
SSH private keys
Database hostnames and ports
API endpoint URLs
Feature flag states
Log levels
Cache TTL values
Service discovery addresses

The Hotel Key Card Analogy

A hotel gives every guest a key card programmed for a specific room, for a specific duration — not a master key, not a permanent key. The card works exactly where it needs to and nowhere else. When the guest checks out, the card is deactivated. Secrets in a CI/CD pipeline should work identically: scoped to the specific job that needs them, valid for the duration of that job's run, and automatically invalidated when the context changes. A secret shared across all jobs in all environments is a master key — and master keys are the thing hotels are most careful never to leave lying around.

GitHub Secrets — Built-In Secret Storage

GitHub Actions provides encrypted secret storage at three levels: repository secrets (available to all workflows in a repository), environment secrets (available only to jobs targeting a specific environment), and organisation secrets (available to selected repositories across an organisation). Environment-scoped secrets are the most important for CI/CD pipelines — they ensure that production credentials are only accessible to jobs that explicitly target the production environment, and staging credentials to staging jobs.

GitHub secrets are encrypted at rest using libsodium sealed boxes, masked in logs automatically — any value that matches a secret is replaced with *** in the workflow output — and are never exposed to the runner's environment in a way that allows them to be read by other processes. They are injected as environment variables at the step level and are not accessible between jobs unless explicitly passed as outputs.

Secrets in a GitHub Actions Pipeline

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production              # Only production-scoped secrets are available here

    steps:
      - name: Deploy to production
        env:
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}         # Injected at step level only
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}       # Masked in all log output
        run: |
          ./deploy.sh \
            --db-password "$DB_PASSWORD" \
            --token "$DEPLOY_TOKEN"

      - name: Notify on success
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}     # Different secret, same pattern
        run: |
          curl -X POST "$SLACK_WEBHOOK" \
            -d '{"text": "Production deployment succeeded"}'

  # This job has NO access to production secrets — different environment scope
  test:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - run: npm test
        env:
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}         # This is the STAGING DB_PASSWORD
                                                          # not the production one

What just happened?

Secrets are injected at the step level rather than exported globally, environment scoping ensures the production DB_PASSWORD is unreachable from the staging job even though both use the same secret name, and all secret values are automatically masked in log output. The staging job gets the staging database password; the production job gets the production one — with no additional code required beyond targeting the correct environment.

External Secrets Managers — Vault, AWS Secrets Manager, and Beyond

GitHub Secrets is sufficient for most pipelines, but larger organisations often centralise secret management in a dedicated secrets manager — HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, or Azure Key Vault. These platforms offer capabilities that GitHub Secrets does not: fine-grained audit logs of every secret access, dynamic secrets that are generated on demand and expire automatically, secret versioning with rollback, and cross-platform availability for secrets used outside of GitHub Actions.

Dynamic secrets are one of the most powerful features of dedicated secrets managers. Rather than storing a long-lived database password, Vault generates a unique, time-limited credential for each pipeline run — valid for 15 minutes, then automatically revoked. If the credential is leaked, the attacker has a narrow window before it expires. This is categorically more secure than rotating a static password on a schedule, and it eliminates the class of incident where a leaked credential remains valid for months because nobody noticed it was compromised.

OIDC and Keyless Authentication — Eliminating Long-Lived Credentials

The most secure secret is one that does not exist. OpenID Connect (OIDC) is a protocol that allows GitHub Actions to authenticate directly to cloud providers — AWS, GCP, Azure — without storing any long-lived credentials in GitHub Secrets at all. Instead, the pipeline requests a short-lived token from the cloud provider at runtime, proving its identity through GitHub's OIDC token. The cloud provider validates the token and grants the permissions defined in a trust policy.

The security advantage is significant. There is no static AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY stored anywhere — nothing to rotate, nothing to leak, nothing to expire and break pipelines at 3am. The trust policy on the cloud provider side defines exactly which repositories, branches, and environments can assume which roles. A compromised repository cannot use OIDC to escalate to a role it was not explicitly granted. This pattern is now the recommended approach for all AWS, GCP, and Azure deployments from GitHub Actions.

OIDC Authentication to AWS — No Stored Credentials

permissions:
  id-token: write                        # Required for OIDC token request
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsDeployRole
          aws-region: eu-west-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY stored anywhere
          # GitHub requests a short-lived token from AWS STS at runtime

      - name: Deploy to ECS
        run: aws ecs update-service --cluster prod --service api --force-new-deployment

What just happened?

The pipeline authenticated to AWS without a single stored credential. GitHub's OIDC provider issued a token proving this workflow run came from the correct repository, branch, and environment. AWS validated that token against its trust policy and issued a short-lived session token scoped to the deploy role. The token expires when the job ends. There is nothing to rotate, nothing to leak, and nothing stored in GitHub Secrets for this authentication path.

Warning: Hardcoded Secrets in Pipeline YAML Are a Permanent Breach

A secret committed to a Git repository — even if deleted in a subsequent commit — remains in the repository's history forever and is trivially recoverable. GitHub's secret scanning will flag known credential patterns, but it cannot protect against patterns it does not recognise, and it cannot un-expose a credential that has already been seen. Treat any credential committed to version control as permanently compromised: revoke it immediately, issue a new one, and audit all systems it had access to. The fix is never "delete the commit" — it is "rotate the credential and understand what had access to it."

Key Takeaways from This Lesson

Secrets and configuration are different categories — only values whose exposure causes harm belong in encrypted secret storage. Treating all per-environment values as secrets adds management overhead without security benefit.
Environment-scoped secrets prevent cross-environment credential access — a job targeting the staging environment cannot read production secrets, even if both environments define a secret with the same name.
Dynamic secrets are more secure than static ones — a credential generated per pipeline run and automatically revoked after 15 minutes is categorically safer than a long-lived password rotated on a quarterly schedule.
OIDC eliminates the need to store cloud provider credentials entirely — GitHub Actions can authenticate to AWS, GCP, and Azure using short-lived tokens issued at runtime, with nothing stored in GitHub Secrets for that authentication path.
A secret committed to Git is permanently compromised — deleting the commit does not remove it from history. Rotate immediately, audit access, and treat the credential as having been fully exposed from the moment it was pushed.

Teacher's Note

If your pipeline authenticates to AWS with a stored access key, migrate to OIDC — it takes about 30 minutes to set up and permanently removes the category of incident where a rotated key breaks production at the worst possible time.

Practice Questions

Answer in your own words — then check against the expected answer.

1. What is the term for credentials generated on demand for a specific pipeline run and automatically revoked after a short time window — a feature of dedicated secrets managers like HashiCorp Vault that is categorically more secure than storing long-lived static passwords?



2. What protocol allows GitHub Actions to authenticate to cloud providers like AWS and GCP at runtime using short-lived tokens — without storing any long-lived access keys or passwords in GitHub Secrets?



3. A developer accidentally commits an API key to a public GitHub repository. They immediately delete the commit and force-push. What must still happen, and why is the delete insufficient?



Lesson Quiz

1. A pipeline has a staging job and a production job. Both reference secrets.DB_PASSWORD. The staging job must connect to the staging database and the production job to the production database. What GitHub mechanism ensures each job gets the correct credential without any conditional logic in the YAML?


2. A team stores their database hostname, database password, and log level all as GitHub encrypted secrets. A senior engineer says one of these does not belong there. Which one and why?


3. A team currently stores AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub Secrets for their production deploy job. A colleague proposes switching to OIDC. What is the concrete security improvement OIDC provides over the current approach?


Up Next · Lesson 24

Environment Variables & Configs

Secrets keep credentials safe. Environment variables and config files carry everything else — the non-sensitive, per-environment values that shape how an application behaves when it runs.