CI/CD with GitHub Actions and Jenkins · Lesson 5 of 9 · about 25 minutes

Secrets and permissions

How a pipeline gets the passwords and tokens it needs without leaking them: GitHub's short-lived token and least-privilege permissions, repository secrets and the limits of log masking, Jenkins credentials and the double-quote mistake, and tokens that don't need storing at all. Jenkins finally pushes its image.

By the end you'll know

  • Which values are secrets, and the places secrets usually leak
  • How GITHUB_TOKEN and permissions: keep a workflow's powers small
  • How repository secrets work, and why log masking isn't enough on its own
  • How Jenkins stores credentials and hands them to a stage
  • Why secrets in a Jenkinsfile belong in single quotes, never double
  • How OpenID Connect (OIDC) lets a pipeline reach a cloud with no stored key at all

Where you are

Your pipelines can build images, and GitHub can push them, using a token it creates for every run. Jenkins can't yet: it has no way to sign in to the registry. This lesson gives it one, and uses that to look at the question every pipeline eventually faces: how do you hand a machine a password without that password ending up somewhere it shouldn't?

The one bit of setup is a GitHub token for Jenkins, which takes a couple of minutes.

Keys, spare keys and hotel cards

Think of the keys to a house. Taping a spare key under the doormat is easy, and it works for anyone who looks under the mat. A key you copy for every friend who visits is soon in pockets you've forgotten about. A hotel keycard is different: it opens one room, it stops working at checkout, and if you lose it, the front desk makes a new one in seconds. Good pipeline secrets are hotel keycards: they open only what they need, they expire, and they're easy to replace.

A secret is any value that lets someone in, or lets them act as you: a password, an API key, a token, a private key. Pipelines need them all the time, to push an image, reach a cloud account or post to a chat channel. And the places they leak are always the same:

  • The repository, including its history. Deleting a password in a new commit doesn't remove it from the old ones.
  • Logs. A step that prints its settings, or a tool that echoes the command it ran, puts the secret on a page everyone with access to the pipeline can read.
  • Command lines. On a shared machine, other programs can see the commands that are running, including any password typed into one.
  • Images. A secret copied into an image during a build stays inside it, in a layer, for anyone who pulls the image.
  • Pull requests from strangers. On a public repository, anyone can propose a change, including one that tries to print the pipeline's secrets.

Not everything that looks sensitive is a secret, though, and treating everything as one makes people careless with the ones that are. Try sorting these:

Try it: secret, or not?

  1. The production database password

  2. A Slack webhook address, like https://hooks.slack.com/services/…

  3. The database host name, like db.internal.example.com

  4. Your AWS account ID, like 123456789012

  5. An SSH public key

  6. The password postgres, for the CI pipeline’s throwaway test database

  7. GITHUB_TOKEN, the token GitHub gives each workflow run

Pick an answer for each one.

In GitHub Actions: a short-lived token, kept small

You've already used a secret without setting one up: GITHUB_TOKEN, in Lesson 4's login step. GitHub creates it for every job, scoped to the repository, and it stops working when the job ends. That's the hotel keycard, and it's why most workflows never need a password of their own for GitHub itself.

What it's allowed to do is set by permissions:, which your ci.yml has used since Lesson 3:

YAML
permissions:
  contents: read

jobs:
  build:
    permissions:
      contents: read
      packages: write

The top-level block makes every job read-only by default. Only the build job, which pushes the image, gets packages: write. If one of the other jobs ran a compromised tool, the token it had could read your code, which is public anyway, and nothing more. This is least privilege: every part gets the smallest set of powers it needs. Lesson 2's log showed the token's permissions line by line, so you can always check what a job actually got.

Repository secrets, and the limits of masking

For anything that isn't GitHub itself, like a cloud key or a Slack webhook, you store a repository secret. GitHub keeps it encrypted, gives it to a workflow only when a step asks for it, and masks it in logs, replacing it with ***. Lesson 3's log already did that to the password inside TEST_DATABASE_URL.

Masking is a safety net, and it's worth seeing exactly how thin it is. Make a pretend secret and a job that prints it two ways. On your laptop, in ~/Apps/cicd-sandbox:

BASH
cd ~/Apps/cicd-sandbox
gh secret set DEMO_SECRET --body "pretend-password-123"
cat >> .github/workflows/ci.yml <<'EOF'

  secret-demo:
    runs-on: ubuntu-24.04
    steps:
      - run: echo "The secret is $DEMO_SECRET"
        env:
          DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
      - run: echo "$DEMO_SECRET" | rev
        env:
          DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
EOF
git commit -am "Show what log masking does and doesn't catch"
git push

The value is made up, so nothing real is at risk. env: hands the secret to just that one step, as an environment variable, which is the safe way in: it never appears in the step's command. The second step runs it through rev, which prints it backwards. A minute later:

BASH
gh run view "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --log | grep -A1 -E "Run echo"

Mine, with the timestamps taken off:

OUTPUT
secret-demo Run echo "The secret is $DEMO_SECRET" ##[group]Run echo "The secret is $DEMO_SECRET"
secret-demo Run echo "The secret is $DEMO_SECRET" env:
secret-demo Run echo "The secret is $DEMO_SECRET"   DEMO_SECRET: ***
secret-demo Run echo "The secret is $DEMO_SECRET" The secret is ***
secret-demo Run echo "$DEMO_SECRET" | rev ##[group]Run echo "$DEMO_SECRET" | rev
secret-demo Run echo "$DEMO_SECRET" | rev env:
secret-demo Run echo "$DEMO_SECRET" | rev   DEMO_SECRET: ***
secret-demo Run echo "$DEMO_SECRET" | rev 321-drowssap-dneterp

GitHub hid the secret everywhere it appeared exactly: in each step's list of settings, and in The secret is ***. Then rev printed 321-drowssap-dneterp, which is pretend-password-123 backwards, in full, because masking looks for the exact value and a reversed copy doesn't match. The same goes for a secret that's encoded, split into pieces, or printed one character at a time. Anyone who can change a workflow can get a secret out this way, which is why the real protection is controlling who can run workflows with secrets, not masking.

Then take the demo out again:

BASH
python3 - <<'EOF'
from pathlib import Path
p = Path(".github/workflows/ci.yml")
s = p.read_text()
p.write_text(s[:s.index("\n  secret-demo:")] + "\n")
EOF
gh secret delete DEMO_SECRET
git commit -am "Remove the masking demo"
git push

Pull requests from forks

On a public repository, anyone can fork it, make a copy, and open a pull request, and that pull request runs your workflow. GitHub protects you by default: a workflow started by a pull request from a fork gets no secrets and a read-only GITHUB_TOKEN, and a first-time contributor's workflow waits for your approval before it runs at all.

There's one trigger that switches those protections off: pull_request_target. It runs with your repository's secrets and a writable token, for workflows that need to label or comment on pull requests. It's safe as long as it never runs the pull request's code. The classic mistake is a pull_request_target workflow that checks out the pull request and runs its tests, which hands a stranger's code your secrets. If you see pull_request_target in a workflow, it deserves a careful read.

No stored key at all: OIDC

The best secret is one you don't have to store. OpenID Connect, or OIDC, lets a workflow prove to a cloud provider who it is, and get short-lived credentials back, with no key saved in GitHub. On AWS, you create an IAM role that trusts GitHub's identity service, for one repository and branch only, and the workflow asks for it:

YAML
permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-deploy
      aws-region: us-east-1

id-token: write lets the job ask GitHub for a signed statement saying "I'm a workflow in this repository, on this branch". AWS checks the signature and the role's rules, and returns credentials that last about an hour. If they leak, they're soon useless, and there was never a long-lived AWS key to steal. That's the hotel keycard again, and it's how most teams connect GitHub to AWS now. The action is pinned to a full commit ID, the way GitHub's own example does it, which Lesson 7 explains.

In Jenkins: the credentials store

Jenkins keeps secrets in its credentials store, encrypted on disk inside jenkins_home, and hands them to a pipeline only inside a withCredentials block. Jenkins needs a GitHub token to push to GHCR, so that's the one to make.

Make a token for Jenkins

GHCR only accepts a personal access token (classic) for pushing images; GitHub's newer fine-grained tokens don't work with it yet. Make one that does as little as possible:

  1. On GitHub, open Settings, Developer settings, Personal access tokens, Tokens (classic), then Generate new token (classic).
  2. Note: jenkins-ghcr-sandbox. Expiration: 30 days.
  3. Scopes: tick write:packages, which also gives read access. GitHub's form ticks repo for you at the same time, and won't let you untick it while write:packages is ticked.
  4. Generate token, and copy it. GitHub shows it once.

Don't paste it anywhere but the next step: not into a file, a chat or a note.

That repo scope is a real problem, and an honest one: it gives the token full access to all your repositories, private ones included, which pushing images doesn't need. When a token can't be made as narrow as you'd like, you limit it in the other ways you have: a short expiry, one place it's stored, and revoking it as soon as it's no longer needed. It's also part of why GITHUB_TOKEN and OIDC exist. On a shared Jenkins, teams often go further, with a separate GitHub account for the pipeline that owns nothing else.

Then store it in Jenkins, at http://localhost:8080:

  1. Manage Jenkins, Credentials, then the (global) domain under System, and Add credentials.
  2. Kind: Username with password. Username: your GitHub username. Password: the token.
  3. ID: ghcr. Description: GHCR push token for the sandbox.
  4. Create.

The ID is how a Jenkinsfile refers to it. The token itself never appears in the Jenkinsfile, the repository or the build log.

Push from Jenkins

Add a Push stage after Build, and a post block that signs out of the registry whatever happened. On your laptop, in ~/Apps/cicd-sandbox:

BASH
python3 - <<'EOF'
from pathlib import Path
p = Path("Jenkinsfile")
s = p.read_text()
end = s.rindex("\n  }\n}")
push = """
    stage('Push') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'ghcr', usernameVariable: 'GHCR_USER', passwordVariable: 'GHCR_TOKEN')]) {
          sh '''
            echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
            docker tag cicd-sandbox:$IMAGE_TAG ghcr.io/kwamib/cicd-sandbox-jenkins:$IMAGE_TAG
            docker push ghcr.io/kwamib/cicd-sandbox-jenkins:$IMAGE_TAG
          '''
        }
      }
    }"""
post = """
  post {
    always {
      sh 'docker logout ghcr.io || true'
    }
  }"""
s = s[:end] + push + "\n  }" + post + "\n}\n"
p.write_text(s)
EOF
tail -22 Jenkinsfile
git commit -am "Jenkins pushes its image to GHCR with a stored credential"
git push

Use your own username, in lowercase, in place of kwamib. The pieces:

  • withCredentials([usernamePassword(...)]) fetches the ghcr credential and puts it in two environment variables, GHCR_USER and GHCR_TOKEN, for the steps inside the block only. Jenkins also masks both in the log.
  • echo "$GHCR_TOKEN" | docker login ... --password-stdin passes the token through a pipe, never as a command-line argument, the same way your StackIT pipeline passed its ECR password.
  • '''single quotes''' mean Jenkins hands the text to the shell as it is, and the shell fills in $GHCR_TOKEN. The token never becomes part of the command Jenkins builds. The next section shows what happens when it does.
  • cicd-sandbox-jenkins is a separate image from GitHub's cicd-sandbox, so the two pipelines never push over each other's tags.
  • docker logout in post { always { } } removes the saved login afterwards. docker login stores it in a file, and on Jenkins that file lives in jenkins_home, where every later build could use it.

Build Now on the sandbox job, then check that no login was left behind:

BASH
docker exec jenkins sh -c 'grep -q "ghcr.io" /var/jenkins_home/.docker/config.json 2>/dev/null && echo "a ghcr.io login is stored" || echo "no ghcr.io login stored"'

My first try, build #4, failed at the very end of the push:

OUTPUT
Masking supported pattern matches of $GHCR_TOKEN
+ echo ****
+ docker login ghcr.io -u kwamib --password-stdin

WARNING! Your credentials are stored unencrypted in '/var/jenkins_home/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/

Login Succeeded
+ docker push ghcr.io/kwamib/cicd-sandbox-jenkins:1.0.4-3b35811
78a50c06ce42: Pushed
ddc0c1e36a67: Pushed
39e50da40234: Pushed
error from registry: denied
+ docker logout ghcr.io
Removing login credentials for ghcr.io
Finished: FAILURE

The login worked and every layer was pushed; only the last step, the manifest that gives the image its tag, was refused. The token was fine: I checked its scopes, and the next build, #5, with nothing changed, went straight through:

OUTPUT
+ docker push ghcr.io/kwamib/cicd-sandbox-jenkins:1.0.5-3b35811
78a50c06ce42: Layer already exists
ddc0c1e36a67: Layer already exists
b79df0f198fb: Pushed
1.0.5-3b35811: digest: sha256:3d01ab4b49dcffd6d4577f18200d2d25bf66751175b0831c9f27f3c39c068917 size: 856
+ docker logout ghcr.io
Removing login credentials for ghcr.io
Finished: SUCCESS

Layer already exists is the layers from #4 that had made it. My best explanation is that GHCR refused the very first manifest for a brand-new package while it was still setting the package up, which is a known, occasional thing. If you hit a denied with a working login, try once more before changing anything. Everything else in these logs is the lesson working:

  • Masking supported pattern matches of $GHCR_TOKEN and + echo ****: Jenkins knows the token's value and hides it wherever it appears in the log.
  • The WARNING is Docker saying exactly why the post block is there: after docker login, the token sits in a plain file inside jenkins_home.
  • Removing login credentials for ghcr.io appears in both builds, including the failed one. That's post { always }: it runs however the build ends.

And the check afterwards:

OUTPUT
no ghcr.io login stored

Break it on purpose: double quotes

The most common Jenkins secrets mistake is one character: " instead of '. With double quotes, Groovy fills in ${GHCR_TOKEN} before the shell ever runs, so the real token becomes part of the command Jenkins builds and passes to the operating system. Change just the login: this takes it out of the single-quoted block and puts it in a step of its own, in double quotes:

BASH
python3 - <<'EOF'
from pathlib import Path
p = Path("Jenkinsfile")
s = p.read_text()
login = '            echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin\n'
s = s.replace(login, "")
s = s.replace("          sh \'\'\'\n            docker tag", '          sh "echo ${GHCR_TOKEN} | docker login ghcr.io -u ${GHCR_USER} --password-stdin"\n          sh \'\'\'\n            docker tag')
p.write_text(s)
EOF
grep -n "docker login" Jenkinsfile

grep should show the new double-quoted line. Then commit, push and Build Now:

BASH
git commit -am "Pass the token with Groovy interpolation, the insecure way"
git push

The Push stage of mine, build #6:

OUTPUT
[Pipeline] { (Push)
[Pipeline] withCredentials
Masking supported pattern matches of $GHCR_TOKEN
[Pipeline] sh
Warning: A secret was passed to "sh" using Groovy String interpolation, which is insecure.
		 Affected argument(s) used the following variable(s): [GHCR_TOKEN]
		 See https://jenkins.io/redirect/groovy-string-interpolation for details.
+ echo ****
+ docker login ghcr.io -u kwamib --password-stdin
Login Succeeded
+ docker push ghcr.io/kwamib/cicd-sandbox-jenkins:1.0.6-4e3c820
1.0.6-4e3c820: digest: sha256:2602e5bcf3a895527bf547dba9a5d9a76d079dac480dd32ea318836724bdd443 size: 856
Finished: SUCCESS

Jenkins spotted it before the step even ran, and named the variable involved: [GHCR_TOKEN].

The build still works, and the token is still masked in the output, so it's easy to miss what went wrong. The warning is the clue: the token was put into the command itself, where other programs on the agent can see it while it runs, and where any small change to it, like a special character being escaped, would stop Jenkins recognising it to mask. Put it back with git revert --no-edit HEAD and git push.

In my real setup

My StackIT pipeline, jenkins-aws-wordpress-pipeline (opens in a new tab), has both versions in one file. The SonarQube stage used the risky one:

GROOVY
sh """
    ${scannerHome}/bin/sonar-scanner \\
        -Dsonar.projectKey=CliXX-App \\
        -Dsonar.login=${SONAR_TOKEN}
"""

${SONAR_TOKEN} inside """ is Groovy interpolation, exactly the warning above. The Restore DB Dump stage did it right: it bound the database password to MYSQL_PWD, an environment variable the mysql command reads by itself, so the password never appeared in a command at all. The fix for the scanner is single quotes, -Dsonar.login=$SONAR_TOKEN, and the shell fills it in.

The bigger change is where that database password lived. It was a Jenkins credential, copied into the app with docker run -e WORDPRESS_DB_PASSWORD=..., which leaves it visible to anyone who can run docker inspect on the server. Today I'd keep it in AWS Secrets Manager, give the server an IAM role that can read only that secret, and have the server fetch it at start-up into a file only root can read, passed to the official WordPress image with WORDPRESS_DB_PASSWORD_FILE. Jenkins would never need the password at all.

Spot the problem

The key that was deleted. A developer commits an AWS access key by mistake, notices an hour later, and pushes a new commit removing it. The repository is public. Are they safe? No. The key is still in the history, where anyone can find it, and bots scan public repositories for keys within minutes of a push. A leaked secret has to be revoked and replaced first. Cleaning the history comes second, and only helps if it's done before anyone copies the repository.

The helpful workflow. A public repository has a workflow that runs on pull_request_target, so it can post test results as a comment. It checks out the pull request's code and runs npm test. Why is that dangerous? pull_request_target gives the workflow your secrets and a writable token, and npm test runs whatever the pull request's code says to run. A stranger's pull request could send your secrets anywhere. Running untrusted code needs pull_request, which has neither.

The token that does everything. A team's Jenkins pushes images with a token that has every scope ticked, including deleting repositories, and never expires, because that was quicker than choosing. What's the risk? If it ever leaks, through a log, a backup or a laptop, it can do anything that person can do on GitHub, forever. A token with only write:packages and an expiry date can push images for a month, and nothing else.

Where you are now

Your pipelines now handle secrets the way real ones should. GitHub uses its own short-lived token, kept to the least it needs with permissions:, and you've seen that masking catches the exact secret but not a changed copy of it. Jenkins pushes its own image with a token from its credentials store, passed in single quotes and signed out afterwards, and you've seen the warning double quotes cause. And you've seen the way to need fewer secrets at all: OIDC for the cloud, and secrets managers and IAM roles for the apps. Lesson 6 is about keeping pipelines working and honest over time: the skip trap from Lesson 3, runs that pile up, jobs that never end, tools that change under you, and a Jenkins server that cleans up after itself.

Check yourself

  1. What makes GITHUB_TOKEN safer than a stored personal token?

  2. Why does only the build job have packages: write in ci.yml?

  3. A workflow prints a secret reversed with rev. What appears in the log?

  4. Why should a Jenkins sh step use single quotes around $GHCR_TOKEN?

  5. A key was committed and then removed in the next commit. What comes first?

  6. What does OIDC let a GitHub workflow do on AWS?

Key terms

  • Secret: a value that lets someone in or act as you, like a password, token or private key.
  • GITHUB_TOKEN: the token GitHub creates for each job, which expires when the job ends.
  • permissions:: the setting that limits what GITHUB_TOKEN can do.
  • Least privilege: giving each part only the powers it needs.
  • Repository secret: a secret stored encrypted in GitHub and handed to workflows on request.
  • Masking: replacing a secret's exact value with *** in logs.
  • Fork: a copy of someone else's repository, from which pull requests can be opened.
  • pull_request_target: a trigger that runs with the repository's secrets, and must never run a pull request's code.
  • OIDC (OpenID Connect): a way for a workflow to prove who it is and get short-lived cloud credentials.
  • Credentials store: where Jenkins keeps secrets, encrypted, for pipelines to use by ID.
  • withCredentials: the Jenkins step that hands a credential to the steps inside it.
  • Personal access token (classic): a GitHub token with chosen scopes and an expiry, used here for GHCR.
  • Revoke: to cancel a secret so it stops working, the first step after any leak.

Go deeper