Skip to main content
ANVISoftware Solutions
Lesson 15 of 17Advanced16 min

Secrets in Pipelines

By the end of this lesson

Use credentials in automation without exposing them in logs.

A pipeline cannot deploy without credentials. It has to push an image to a registry, tell a platform to run it, and often apply a migration to a database. Every one of those actions needs something that proves the pipeline is allowed to do it.

A pipeline is also the most thoroughly recorded place in your system. Every command's output is captured, stored, attached to a build number, and left readable by anyone who can see the build history. That is the whole point of a pipeline — you want to know what happened — and it means the place holding your deployment credentials is also the place that writes everything down.

This lesson is about holding those two facts at once. The earlier secrets lesson covered where an application keeps its credentials at run time. This one covers the automation, where the failure mode is different: not a value committed to a repository, but a value printed by a script that was trying to be helpful.

The pieces, with both providers' names where they differ:

Pipeline secret store
Where the pipeline platform keeps values you do not want in the repository. GitHub Actions calls them encrypted secrets; Azure DevOps calls them secret variables, optionally backed by a Key Vault variable group. Values are write-only from the interface: you can replace one, and you cannot read it back.
Masking
The platform searching log output for values it knows are secret and replacing them with asterisks before the log is stored. Every major platform does it, and it is a filter on a text stream rather than a rule about who may see what.
Federated identity
A trust relationship that lets the pipeline exchange a token the platform issues about the running job for a short-lived cloud credential. Azure calls the configuration workload identity federation on an app registration or a user-assigned managed identity; AWS calls it an IAM OIDC identity provider with a role the job assumes. The point is the same: no long-lived key is stored anywhere.
Environment-scoped secret
A credential attached to a deployment environment rather than to the repository, so only a job that targets that environment can obtain it. A staging job cannot read production credentials, because it never enters the production environment.
Deployment principal
The identity the pipeline acts as in your cloud account. A service principal with a role assignment on Azure, an IAM role on AWS. Its permissions decide what a mistake or a stolen token can reach.
Fork pull request
A proposed change from a copy of your repository, submitted by someone who cannot push to it. The code in it is untrusted, and it wants to run in your pipeline. That combination is where several real supply-chain incidents have come from.

Most pipelines start with a key pasted into the secret store, because it works in ten minutes. It is worth seeing what the alternative changes before deciding that is good enough.

 A long-lived key in the pipelineA short-lived token from federated identity
What is storedA client secret or access key, valid for months or until someone remembers to rotate itNothing secret. Three identifiers that grant nothing on their own
If it leaksIt keeps working until it is revoked, and you may not know it leakedIt expires in minutes, and it was issued for one job run
RotationYour process, on a calendar, in every pipeline that holds a copyNot applicable. There is no stored credential to rotate
Who can use itAnyone who can run a workflow that reads that secret, including a workflow added in a pull requestOnly a job matching the trust condition: this repository, this environment, this branch
Audit trailA sign-in from a service principal, with no link back to which build it wasToken issuance recorded on both sides, tied to the repository and job that requested it
Setup effortPaste a valueRegister a provider once per account, then one trust condition per environment
Reasonable whenA pipeline platform or a target that genuinely has no federation support. State that as the reason, and scope the key tightlyAnything deploying to a cloud account you care about. This is the fix that removes the problem rather than managing it
A production deploy job that stores no key at all
YAML
name: Deploy employees API

on:
  push:
    branches: [main]        # only merged code deploys; a fork cannot trigger this

permissions:
  contents: read
  id-token: write           # lets this job request a token about itself

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production # reviewers and any environment secrets live here
    steps:
      - uses: actions/checkout@v4

      - name: Sign in without a stored credential
        uses: azure/login@v2
        with:
          # Identifiers, not secrets. Placeholder values shown here.
          client-id: 00000000-0000-0000-0000-000000000000
          tenant-id: 11111111-1111-1111-1111-111111111111
          subscription-id: 22222222-2222-2222-2222-222222222222

      - name: Deploy the digest that passed staging
        run: ./scripts/deploy.sh employees-api-prod "$(cat digest.txt)"
  • The single added permission is what makes this work. The job asks the pipeline platform for a signed token describing itself — which repository, which branch, which environment — and hands that to the cloud provider, which trusts it because you configured the trust in advance.
  • The three identifiers can sit in the file in plain text. They name an application, a directory and a subscription. Presenting them without a valid token gets you nothing, which is why this job has no secret to protect.
  • The credential the job ends up holding is valid for about an hour and was issued for this run. A leaked build log from last month contains nothing usable.
  • The environment line does two jobs. It attaches reviewers, and it scopes credentials: any secret configured on the production environment is unavailable to jobs that do not target it. A staging deploy job literally cannot read them.
  • The trigger is a push to the shared branch, so a pull request cannot run this workflow at all. That is deliberate, and it is the second line of defence rather than the first — the first is that this workflow holds nothing worth reaching.
  • AWS is the same shape with different names: aws-actions/configure-aws-credentials with a role-to-assume and no access key, backed by an OIDC identity provider in the account. Azure DevOps has workload identity federation for service connections. The mechanism differs; the absence of a stored key is the part that matters.
What masking covers, and what it does not
Shell
# 1. The safety net working. A value the platform injected is replaced in log output.
echo "$DB_ADMIN_PASSWORD"                      # prints ***

# 2. The same secret, revealed three ways. None of these is masked, because the
#    bytes written to the log no longer match the stored value.
echo "$DB_ADMIN_PASSWORD" | base64             # a readable encoding of it
echo "$DB_ADMIN_PASSWORD" | fold -w 1          # one character per line
curl -v -u "api:$DB_ADMIN_PASSWORD" https://employees-api.example.net/health

# 3. Not log output at all, so masking never looks at it.
cp ~/.config/deploy-settings.json ./upload/    # kept as a downloadable artifact

# 4. A value fetched during the run is unknown to the masker until you register it.
MIGRATION_PW=$(az keyvault secret show --vault-name anvi-employees-prod \
  --name employees-migration-password --query value -o tsv)
echo "::add-mask::$MIGRATION_PW"               # replaced in output from here on

# 5. To check a credential is present, measure it. Never print it.
if [ -z "$DB_ADMIN_PASSWORD" ]; then echo "password is not set" >&2; exit 1; fi
  • Masking is a substring replacement on the log stream. It looks for the exact value the platform was given, which means anything that changes the bytes walks straight past it: encoding, escaping, compressing, or printing the value one character at a time.
  • Nobody writes block two on purpose. It arrives during a bad afternoon, as a base64 step while debugging an authentication header, or as a verbose curl added to find out why a call was rejected. Basic authentication puts the credential in a header, and verbose mode prints headers.
  • Block three is the gap people miss entirely. Masking applies to console output, not to files. A settings file, a core dump, a test report or a database export uploaded as an artifact is stored as-is and downloadable by anyone with access to the build.
  • Block four matters once you start doing the right thing. Reading a credential from a cloud secret store at run time means the pipeline platform never saw it, so it cannot mask it. Registering it costs one line, and the earlier line that fetched it has already been logged if command tracing is on.
  • That last point deserves stating on its own: shell tracing, which set -x turns on, prints every command with its arguments expanded. It is a useful debugging tool and it will print your secret with no involvement from you.
  • Block five is the habit to build. Almost every accidental disclosure starts as a check that a value arrived, and a length test answers that question completely without putting the value anywhere.
Trust policy — which job may assume the production deployment role
JSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:anvi/employees-api:environment:production"
        }
      }
    }
  ]
}
  • This document answers one question: whose token is accepted. The subject condition pins it to one repository and one deployment environment, so a job in any other repository presenting a perfectly valid token is refused.
  • Leaving that condition out, or widening it with a wildcard, is the mistake worth understanding. Without it the role trusts the identity provider rather than a specific caller, which means any repository on the platform can assume your production role. The trust is the control here, so the condition is the control.
  • Pinning to an environment rather than a branch is usually the better choice, because the environment is where your reviewer requirements sit. A branch condition is fine too, and both beat trusting the whole repository.
  • A fork cannot produce this subject. The token describes the repository the job ran in, not the code it checked out, and a fork's workflow runs under the fork's own identity.
  • This is only half of least privilege. The permissions policy attached to the role is the other half, and it should list the specific actions the deployment needs — update this one service, pull from this one registry — rather than a wildcard. A deployment credential that can also read the employee database is a credential doing two jobs.
  • On Azure the same two halves exist under different names: a federated credential on the identity, holding the issuer, subject and audience, and a role assignment scoped to a resource group rather than the subscription. Write both down when you set it up, because the scope is the part nobody revisits.

Summary

  • A pipeline needs credentials to deploy, and it is also the place that records everything it does, so both facts have to be designed for
  • Masking replaces known values in log output only, so encoding, escaping and uploaded files walk past it
  • The real fix is holding no long-lived key: federated identity exchanges a token about the job for a credential that expires within the hour
  • Scope the deployment principal to the resources it must change, and pin the trust condition to one repository and one environment
  • Keep untrusted code away from production credentials, and treat any secret that has reached a log as compromised and rotate it

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Which of these ends up readable?

The pipeline holds the employees database password as a masked secret. For each step, say whether the value reaches the stored log in readable form: printing it directly; writing it into a connection string that the .NET configuration dump prints at start-up; sending it to a health endpoint with verbose curl; writing it into a JSON settings file that is uploaded as a build artifact; running the deployment script with shell tracing on.

Then answer the question underneath all of them: what would you change so the answer stops mattering?

Show solution

Printing it directly is masked. That is the one case masking is built for, and it is also the one people worry about most.

The configuration dump usually is masked too, because the password appears in the output as the same bytes the platform was given. Usually is doing a lot of work in that sentence: if the configuration provider escapes or re-encodes anything, the match fails.

Verbose curl with basic authentication is not masked. The credential is sent base64-encoded inside a header, and that encoding does not match the stored value.

The artifact is not masked. Masking applies to log output, and a file is not log output. It is stored intact and anyone with build access can download it.

Shell tracing is masked for the arguments that are the secret verbatim, and not for anything the script derives from it. Treat a traced deployment script as likely to leak.

The change that makes the question uninteresting is federated identity plus scope. If the pipeline holds no long-lived credential, a leaked log contains a token that expired the same afternoon, and if the role it assumed could only update one service, that token could not have read the database anyway. Masking then protects the small number of secrets that genuinely have to exist.

Try it yourself

Audit what your pipeline holds

List every secret configured in one pipeline. For each one, write down what it unlocks, when it was last rotated, who could read the build logs of the workflows that use it, and whether federated identity could replace it.

If you have no pipeline to hand, do it for the employees API as described in this course: a container registry, a deployment target, and a database migration.

Show solution

The expected outcome for the employees API is that two of the three disappear. Pushing to the registry and updating the platform are both cloud actions, so a federated identity covers them with no stored value. What tends to remain is the database credential, because a database login is not always reachable through cloud identity.

Where the database does support platform identity, that one goes too, and the pipeline holds nothing. Where it does not, you are left with one secret to protect properly rather than several to protect approximately, which is a much easier job.

The rotation date is the question that usually produces silence. A key nobody has rotated since the pipeline was built is the strongest available argument for federation, because it shows the process you were relying on is not running.

The question about who can read the logs is there to make the exposure concrete. It is common to find that the production deployment credential is used by a workflow whose logs are readable by everyone in the organisation, which is a different risk from the one people picture when they say the secret is encrypted.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why is log masking described as a safety net rather than a control?
A colleague finds the staging database password in a build log from three weeks ago, and deletes the build. What still needs to happen?

Saved in this browser only.