Credentials binding done right
withCredentials, masking, and scope.
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.
pipeline {agent anystages {stage('Push image') {steps {withCredentials([usernamePassword(credentialsId: 'registry-creds', // an id, never the valueusernameVariable: 'REG_USER',passwordVariable: 'REG_PASS')]) {sh '''echo "$REG_PASS" | docker login \-u "$REG_USER" --password-stdin registry.acme.internaldocker push registry.acme.internal/web:$BUILD_NUMBER'''}}}}}
[Pipeline] withCredentialsMasking supported pattern matches of $REG_PASS[Pipeline] {[Pipeline] shWARNING! Your password will be stored unencrypted in /root/.docker/config.json.Login SucceededThe push refers to repository [registry.acme.internal/web]5f70bf18a086: Pushed42: 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.
pipeline {agent anyenvironment {GH_TOKEN = credentials('github-token') // secret text credential}stages {stage('Check release') {steps {sh '''set -xcurl -s -H "Authorization: token $GH_TOKEN" \https://api.github.com/repos/acme/web/releases/latest'''}}}}
[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.
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.
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.
credentials:system:domainCredentials:- credentials:- usernamePassword:scope: GLOBALid: "registry-creds"username: "svc-deploy"password: "${REGISTRY_PASSWORD}" # read from env at load timedescription: "ACME container registry"- string:scope: GLOBALid: "github-token"secret: "${GITHUB_TOKEN}"description: "GitHub API token for releases"
$ java -jar jenkins-cli.jar -s http://localhost:8080/ -auth @token \list-credentials system::system::jenkinsDomain (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.
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.