CoursesJenkins foundations, done rightCredentials binding done right

Credentials binding done right

withCredentials, masking, and scope.

Intermediate14 min · lesson 12 of 16

Rule one: a secret never goes into Git

You are writing a pipeline that pushes a Docker image to your company registry (the server that stores your container images), and the registry wants a username and a password. The tempting move, pasting them straight into the Jenkinsfile or into an environment { REG_PASS = 'hunter2' } block, is also the most dangerous one. Your Jenkinsfile lives in Git, and Git remembers everything. Delete the line tomorrow and the password still sits in the history, one command away. Everyone with read access to that repository has it now, and so does every clone and every fork. Credentials binding is Jenkins' answer, and it behaves like a cloakroom ticket: you hand the coat over once, you walk away with a numbered stub, and the stub is worthless to whoever picks your pocket. The secret is stored a single time, encrypted, inside Jenkins. Your pipeline names it only by a short id, a label such as registry-creds, and never by its value.

That encrypted store lives in JENKINS_HOME (the controller's data directory, where Jenkins keeps its own state) in a file called credentials.xml. The secret bytes in that file are scrambled with a key belonging to that one Jenkins install, kept under $JENKINS_HOME/secrets/. Copy credentials.xml onto a laptop without that key and you have copied noise. When a build genuinely needs the secret, a binding does the work. A binding is a step that decrypts one stored credential, hands it to your build as an environment variable, and unsets that variable the moment the step finishes. The plaintext is out in the open for the narrowest window the design allows, and it never gets written back to disk.

withCredentials: lend the secret for a few lines

The workhorse is the withCredentials step, which comes from the Credentials Binding plugin (a plugin is a unit of installable Jenkins functionality, and this one ships with Jenkins already). You hand the step a list of bindings. The one you will reach for most is usernamePassword, which takes the credentialsId of a stored username and password pair, plus two variable names you pick yourself: usernameVariable and passwordVariable. Inside the block, those two environment variables hold the decrypted values. Outside the block, they do not exist. Here is the safe way to log in to a registry.

Jenkinsfile
pipeline {
agent any
stages {
stage('Push image') {
steps {
withCredentials([usernamePassword(
credentialsId: 'registry-creds', // an id, never the value
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh '''
echo "$REG_PASS" | docker login \
-u "$REG_USER" --password-stdin registry.acme.internal
docker push registry.acme.internal/web:$BUILD_NUMBER
'''
}
}
}
}
}
Console Output
[Pipeline] withCredentials
Masking supported pattern matches of $REG_PASS
[Pipeline] {
[Pipeline] sh
WARNING! Your password will be stored unencrypted in /root/.docker/config.json.
Login Succeeded
The push refers to repository [registry.acme.internal/web]
5f70bf18a086: Pushed
42: digest: sha256:9b2a1f... size: 1160
[Pipeline] }
[Pipeline] // withCredentials

Read the line Masking supported pattern matches of $REG_PASS. When the binding opens, Jenkins registers the decrypted password with the console log filter, which works like a find and replace that knows exactly one search term: every later appearance of that string is rewritten to ****. Notice something else in the shell script. The password goes into docker login through --password-stdin instead of riding along as -p hunter2 on the command line. Anything you pass as a command-line argument shows up in the process list on the agent, readable by anyone with a shell there and the ps command. Masking cannot reach inside that.

The credentials() shortcut in declarative pipelines

Declarative pipelines offer a shorter route. Assign credentials('id') to a variable in an environment block and Jenkins binds it for the whole of that block or stage. What you get back depends on the type of credential you stored. A secret text credential holds one opaque string and nothing more, an API token for instance (API is short for application programming interface, the doorway one program uses to call another), and you get that string back exactly as stored. A username and password credential arrives as one label with three envelopes: NAME holds username:password, NAME_USR holds the username on its own, and NAME_PSW holds the password on its own. That catches beginners out, because they asked for one variable and got three. Here it is with a secret-text GitHub token.

Jenkinsfile
pipeline {
agent any
environment {
GH_TOKEN = credentials('github-token') // secret text credential
}
stages {
stage('Check release') {
steps {
sh '''
set -x
curl -s -H "Authorization: token $GH_TOKEN" \
https://api.github.com/repos/acme/web/releases/latest
'''
}
}
}
}
Console Output
[Pipeline] sh
+ curl -s -H 'Authorization: token ****' https://api.github.com/repos/acme/web/releases/latest
{
"tag_name": "v41",
"name": "Release 41",
"draft": false
}

The set -x line tells the shell to echo every command before it runs it, so the curl line lands in the log word for word. Look at what printed: the token is already ****. That is masking catching an accidental disclosure. It is also a hint about the limits of the net. Masking rescued you here only because the token appeared as the exact stored string. Change its shape in any way and the net has nothing left to catch.

Masking is a safety net, not a wall
Log masking matches one thing: the exact stored string. Base64-encode the secret, print its first four characters, or write it to a file that a later archiveArtifacts step uploads, and the value that reaches the log no longer matches what Jenkins is watching for. The filter stays quiet and the credential sits there in plain sight. So never echo or debug-print a secret, keep every secret inside a withCredentials scope, and never write one to a file that outlives the block.

Scope: who is allowed to ask for the secret

Every credential carries a scope, which decides which parts of Jenkins may bind it. A scope works like the access level on an office badge: same piece of plastic, very different set of doors. Most credential leaks start with the wrong badge. SYSTEM scope means only Jenkins' own configuration may use the secret, for example when the controller logs in to a cloud provider to start agents, and a Pipeline job cannot bind it at all. GLOBAL scope means any job anywhere on the controller may bind it. A folder store attaches the credential to one folder (a folder is a container that groups related jobs), so only jobs inside that folder can see it. Least privilege gives you the rule: a production secret belongs in the production folder, not in GLOBAL, where a throwaway experiment or a pull-request build from an outside contributor could bind it too.

Diagram
New credential: which scope?
Scope decides the blast radius: who can bind it
Jenkins core needs it (agent / cloud connectors)
SYSTEM
Invisible to Pipeline jobs; controller config only
Many jobs share one account
GLOBAL
Any job on the controller can bind it, widest reach
One team or one environment
Folder store
Only jobs in that folder, smallest blast radius

One more boundary deserves a look before you trust a setup. withCredentials decrypts the secret on the controller (the Jenkins brain that schedules the work), then ships the plaintext to wherever the sh step actually runs, which is usually a separate agent machine (a worker whose whole purpose is executing builds). On that agent your secret is an ordinary environment variable in an ordinary shell process. If the agent is shared, or borrowed, or you did not build it yourself, treat every secret it touches as known to whoever controls that machine. Bind production credentials only in pipelines that run on agents you trust.

Create credentials in code, not with a mouse

Adding credentials through the web interface is fine while you are learning, but you cannot code-review a mouse click, and you cannot replay one after a disaster. Define them with Configuration as Code, known as JCasC, which is a YAML file (YAML is a plain-text format for writing configuration) that Jenkins reads at startup. The real secret is pulled from an environment variable as the file loads, so the plaintext never lands in the YAML you commit.

jenkins.yaml (JCasC)
credentials:
system:
domainCredentials:
- credentials:
- usernamePassword:
scope: GLOBAL
id: "registry-creds"
username: "svc-deploy"
password: "${REGISTRY_PASSWORD}" # read from env at load time
description: "ACME container registry"
- string:
scope: GLOBAL
id: "github-token"
secret: "${GITHUB_TOKEN}"
description: "GitHub API token for releases"
jenkins-cli
$ java -jar jenkins-cli.jar -s http://localhost:8080/ -auth @token \
list-credentials system::system::jenkins
Domain (global)
====================================================================
Id Name
====================================================================
registry-creds svc-deploy/****** (ACME container registry)
github-token GitHub API token for releases
====================================================================

The ${REGISTRY_PASSWORD} syntax tells JCasC to fetch that value from an environment variable at load time, so the repository holds nothing but a pointer. After a reload, list-credentials confirms the entries exist. (jenkins-cli is the Jenkins command-line client, a small Java program that talks to the controller from a terminal.) The password comes back as ******, because even the command-line client refuses to print it.

Three failures turn up again and again. A 'Credentials ... is not found' error is nearly always a scope mismatch: a SYSTEM credential referenced from a job, or a folder credential referenced from a job outside that folder. Failing that, it is a typo in the id. A secret that appears unmasked in the log was transformed before it printed, because masking matches only the exact stored bytes. And a credential that works on your laptop but fails on an agent usually means the shell mangled a special character in the password, so quote "$VAR" every time and prefer passing over stdin (--password-stdin) to interpolating the value into a command.

Quick check
01You store a production deploy token with SYSTEM scope, then reference it from a withCredentials block in a Pipeline. The build dies with: Credentials 'prod-deploy' is not found. What went wrong?
Incorrect — Scope is the whole story here. A perfectly spelled id still fails when the scope hides the credential from the job.
Correct — SYSTEM scope covers controller-level plumbing such as agent connectors. Move the token into a folder-scoped store on the production folder, so only production jobs can bind it.
Incorrect — No such per-build approval exists. Scope decides which context may bind a credential, not who signs off on a run.
Incorrect — A restart changes nothing. A Pipeline job can never bind a SYSTEM credential, restarted or not.
02In a declarative pipeline you write GH = credentials('acme-user-pass') in the environment block, and 'acme-user-pass' is a username/password credential. Apart from GH itself, what does Jenkins bind?
Incorrect — That is how a secret text credential behaves. A username/password credential expands into three variables instead.
Incorrect — The suffixes are wrong. The credentials() helper uses _USR and _PSW, so GH_USER and GH_PASS would both be undefined.
Incorrect — Only the bare GH holds the combined form. _USR and _PSW hold the username and the password separately.
Correct — A username/password credential expands to three variables: GH (username:password), GH_USR and GH_PSW.
03While debugging, a teammate adds a line inside a withCredentials block that binds a secret text token as $TOKEN: sh 'echo -n "$TOKEN" | base64'. The base64 string shows up in the console log in full, with no masking. Why?
Correct — Masking matches the literal stored value and nothing else, so any transformation (base64, a substring, a hash) walks straight past it.
Incorrect — Piping has no effect on masking. The filter went quiet because it could not recognize the transformed value as the secret.
Incorrect — Jenkins does no such reasoning. It compared the printed string against the stored secret, found no match, and let it through.
Incorrect — The binding worked and $TOKEN held the secret. The transformation caused the leak, not the naming.

Next up: shared libraries. You will take a pattern like this withCredentials login and wrap it in a reusable function that lives in one repository, so every team calls the safe version by default instead of copy-pasting credential handling into a hundred Jenkinsfiles. Before you move on, open your own Jenkins and answer one question: how many credentials are sitting in GLOBAL scope that would be happier in a folder?

Try this

Work through “Create credentials in code, not with a mouse” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: masking is a safety net, not a wall. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related