Jenkins Security Hardening: Authentication, Plugin Management, and Agent Isolation
Problem
Jenkins remains one of the most widely deployed CI/CD platforms, and it is consistently listed among the most frequently exploited systems in production environments. The combination of complexity (thousands of plugins), age (many deployments are a decade old), and privileged access (credentials for every system it deploys to) makes Jenkins an attractive target.
Common vulnerabilities and misconfigurations:
- Anonymous access to the Jenkins UI and API. Jenkins ships with security disabled by default in older versions. Many production instances have anonymous read access enabled, exposing build logs, environment variables, and credential names.
- Default admin credentials. The initial setup wizard may be bypassed, leaving credentials as
admin/admin. Credential stuffing tools target Jenkins instances at known ports (8080, 8443). - Agents running as root or with host mounts. Jenkins agents execute build jobs. An agent configured with
--privilegedDocker access, or running as root with host filesystem mounts, gives any pipeline code root access to the host. - Unapproved plugin installation. Jenkins plugins run with the same JVM permissions as the Jenkins controller. A malicious plugin — or a legitimate plugin with a supply chain compromise — has access to all stored credentials, can exfiltrate secrets from the credential store, and can modify pipeline execution.
- Script console access to admins. The Jenkins script console provides a Groovy interpreter with full JVM access. Any admin (or any user with the Run Scripts permission) can execute arbitrary code in the Jenkins JVM and read all stored credentials.
- Exposed JNLP agent port. The JNLP agent port (50000) allows agents to connect to the controller. An attacker who can reach this port can attempt to connect a rogue agent and intercept jobs or inject malicious build steps.
- No build isolation. Build jobs run in shared workspaces. A pipeline can read artefacts from previous jobs, access workspace data from concurrent jobs, or persist files to influence future job execution.
Target systems: Jenkins LTS 2.440+ (Java 17+); Jenkins Kubernetes Plugin for ephemeral agents; Jenkins Configuration as Code (JCasC) 1.58+; Matrix Authorization Strategy Plugin; Credentials Binding Plugin.
Threat Model
- Adversary 1 — Unauthenticated API access: An attacker reaches the Jenkins URL (public IP, misconfigured Ingress) and accesses the REST API anonymously. They enumerate jobs, read build logs (containing secrets printed to console), and download build artefacts.
- Adversary 2 — Credential exfiltration via script console: An attacker who has compromised an admin account (phishing, credential stuffing) uses the Groovy script console to iterate over the Jenkins credential store and print all stored credentials in plaintext.
- Adversary 3 — Malicious pipeline code execution on agent: A developer (or an attacker with developer-level access) commits a
Jenkinsfilethat mounts the host Docker socket, reads the agent’s IAM role credentials, or exfiltrates the Jenkins agent’s workspace files. - Adversary 4 — Compromised plugin supply chain: An attacker compromises the release process for a widely-used Jenkins plugin. The plugin update distributed via the Jenkins Update Centre contains a backdoor that reads the Jenkins credential store at startup.
- Adversary 5 — JNLP rogue agent: An attacker connects a rogue agent to the Jenkins controller via the JNLP port. The controller assigns jobs to the rogue agent. Build credentials are transmitted to the rogue agent’s environment.
- Access level: Adversaries 1 and 2 need network access and possibly valid credentials. Adversary 3 needs pipeline commit access. Adversary 4 exploits the plugin supply chain. Adversary 5 needs network access to port 50000.
- Objective: Extract all credentials from the Jenkins credential store; execute arbitrary code; compromise all systems Jenkins deploys to.
- Blast radius: Jenkins credential stores contain deployment keys, cloud provider credentials, database passwords, and TLS certificates for every system it manages. Full credential exfiltration is a complete infrastructure compromise.
Configuration
Step 1: Enable and Enforce Authentication
// Configure via Jenkins Configuration as Code (JCasC).
// /var/jenkins_home/casc_configs/security.yaml
jenkins:
securityRealm:
# Use SSO via OIDC (Okta, Azure AD, Google).
# Requires the OpenID Connect Authentication Plugin.
oicAuth:
clientId: "${JENKINS_OIDC_CLIENT_ID}"
clientSecret: "${JENKINS_OIDC_CLIENT_SECRET}"
wellKnownOpenIDConfigurationUrl: "https://company.okta.com/oauth2/default/.well-known/openid-configuration"
userNameField: "email"
groupsFieldName: "groups"
disableSslVerification: false
authorizationStrategy:
# Matrix-based security: explicit grants per user/group.
projectMatrix:
permissions:
# Admins: full access.
- "GROUP:hudson.model.Hudson.Administer:jenkins-admins"
# Developers: read jobs and trigger builds on specific folders.
- "GROUP:hudson.model.Item.Read:jenkins-developers"
- "GROUP:hudson.model.Item.Build:jenkins-developers"
- "GROUP:hudson.model.Item.Cancel:jenkins-developers"
# Anonymous: no access. This line must be absent (or explicitly denied).
# Never add: "USER:hudson.model.Hudson.Read:anonymous"
# Disable CLI over remoting (use HTTP CLI or disable entirely).
slaveAgentPort: -1 # Disable JNLP port if using Kubernetes agents only.
crumbIssuer:
standard:
excludeClientIPFromCrumb: false # CSRF protection; keep enabled.
# Disable Old Data Monitor and other diagnostic endpoints that expose config.
disabledAdministrativeMonitors:
- "jenkins.security.RekeySecretAdminMonitor"
Disable the script console for non-admin users (and restrict admin access):
// Via JCasC — remove Run Scripts permission from all non-admin roles.
// In Matrix Authorization, do NOT grant:
// hudson.model.Hudson.RunScripts to any non-admin group.
// Enforce via Groovy init script (runs at startup):
// /var/jenkins_home/init.groovy.d/disable-script-console.groovy
import jenkins.model.Jenkins
import hudson.security.Permission
def instance = Jenkins.instance
// Verify script console is restricted to admins only — alert if not.
def strategy = instance.authorizationStrategy
// Log a warning if anonymous has any permissions.
Step 2: Plugin Management and Minimisation
# Audit currently installed plugins.
curl -s -u "admin:${JENKINS_TOKEN}" \
"https://jenkins.internal/pluginManager/api/json?depth=1" | \
jq -r '.plugins[] | "\(.shortName) \(.version) \(.active)"' | \
sort
# Check for plugins with known CVEs.
# Jenkins Security Advisory: https://www.jenkins.io/security/advisories/
# Use the Jenkins CLI to list outdated plugins.
java -jar jenkins-cli.jar -s https://jenkins.internal \
-auth admin:$TOKEN \
list-plugins | grep "available"
# JCasC: pin plugin versions — prevent automatic updates pulling untested versions.
# plugins.yaml (used with plugin-installation-manager-tool)
plugins:
- groupId: "org.jenkins-ci.plugins"
artifactId: "kubernetes"
version: "4029.v5712230ccb_f8"
- groupId: "org.jenkins-ci.plugins"
artifactId: "workflow-aggregator"
version: "596.v8c21c963d92d"
# Remove unused plugins:
# - Do not include matrix-project, subversion, cvs, clearcase, or other
# plugins not in active use.
# Disable unused built-in plugins.
java -jar jenkins-cli.jar -s https://jenkins.internal -auth admin:$TOKEN \
disable-plugin ant subversion cvs mercurial \
windows-slaves ssh-slaves
# Verify security updates are applied (weekly process).
# jenkins.io/security/advisories — subscribe to email alerts.
Step 3: Kubernetes Ephemeral Agents
Replace persistent agents with ephemeral Kubernetes pods. Each build gets a fresh, isolated pod:
# JCasC: configure Kubernetes cloud for ephemeral agents.
jenkins:
clouds:
- kubernetes:
name: "kubernetes"
serverUrl: "" # Empty = use in-cluster config.
namespace: "jenkins-agents"
jenkinsUrl: "http://jenkins.jenkins-controller:8080"
jenkinsTunnel: "" # JNLP disabled; use WebSocket.
webSocket: true # WebSocket agent protocol (no JNLP port needed).
podRetention: "never" # Delete pod immediately after job completes.
templates:
- name: "default"
serviceAccount: "jenkins-agent" # Scoped SA, not cluster-admin.
containers:
- name: "jnlp"
image: "jenkins/inbound-agent:latest-jdk17"
resourceRequestCpu: "500m"
resourceRequestMemory: "512Mi"
resourceLimitCpu: "2"
resourceLimitMemory: "2Gi"
# Security context for the agent pod.
runAsNonRoot: true
runAsUser: 1000
# No host path mounts. No Docker socket.
volumes: []
# Prevent agent pods from escalating privileges.
activeDeadlineSeconds: 3600 # Kill pods running over 1 hour.
# RBAC for jenkins-agent service account — minimal permissions.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: jenkins-agent
namespace: jenkins-agents
rules:
# Agents need to create/delete their own pods.
- apiGroups: [""]
resources: ["pods", "pods/exec", "pods/log"]
verbs: ["get", "list", "watch", "create", "delete"]
# NOT: secrets, configmaps, deployments, or any cluster-wide resources.
Step 4: Credential Security
// Use Jenkins Credentials Binding Plugin — credentials injected as environment
// variables, never printed to console.
// Jenkinsfile — credential injection.
pipeline {
agent { label 'kubernetes' }
stages {
stage('Deploy') {
steps {
withCredentials([
// Inject as masked environment variable.
string(credentialsId: 'aws-api-key', variable: 'AWS_ACCESS_KEY'),
// SSH key injected to temp file, cleaned up after step.
sshUserPrivateKey(credentialsId: 'deploy-key',
keyFileVariable: 'SSH_KEY',
usernameVariable: 'SSH_USER'),
]) {
// AWS_ACCESS_KEY is masked in all log output.
sh 'aws s3 sync ./dist s3://my-bucket/'
}
// SSH_KEY temp file is automatically deleted.
}
}
}
}
# Store Jenkins master credentials in external secrets manager, not Jenkins credential store.
# Use the HashiCorp Vault Plugin or AWS Secrets Manager Credentials Provider.
# JCasC: configure Vault integration.
unclassified:
hashicorpVault:
configuration:
vaultUrl: "https://vault.internal.example.com"
vaultCredentialId: "vault-approle" # AppRole for Jenkins to authenticate.
engineVersion: 2
Step 5: Network Access Controls
# Restrict Jenkins access to internal networks only.
# Ingress with IP allowlist.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: jenkins
namespace: jenkins-controller
annotations:
nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
# Rate limit login attempts.
nginx.ingress.kubernetes.io/limit-rps: "5"
nginx.ingress.kubernetes.io/limit-connections: "10"
spec:
rules:
- host: jenkins.internal.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: jenkins
port:
number: 8080
# Block the JNLP agent port from external access.
# If using Kubernetes WebSocket agents, JNLP (50000) is not needed at all.
# Disable in JCasC:
# jenkins:
# slaveAgentPort: -1 # Disabled.
# If JNLP required, restrict to agent subnet only.
nft add rule inet filter input \
tcp dport 50000 \
ip saddr != 10.200.0.0/24 \ # Agent subnet only.
drop
Step 6: Build Isolation
// Jenkinsfile: prevent workspace pollution between builds.
pipeline {
agent {
kubernetes {
// Each build gets a fresh pod with clean workspace.
yaml '''
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: build
image: maven:3.9-eclipse-temurin-17@sha256:abc123
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
resources:
limits:
cpu: "2"
memory: "4Gi"
'''
}
}
options {
// Clean workspace before each build.
cleanWs()
// Timeout: kill builds that run too long.
timeout(time: 60, unit: 'MINUTES')
// Discard old build logs.
buildDiscarder(logRotator(numToKeepStr: '30'))
}
}
Step 7: Audit Logging
// Enable audit logging via the Audit Trail Plugin.
// JCasC:
unclassified:
auditTrailPlugin:
loggers:
- logFile:
log: "/var/log/jenkins/audit.log"
logSeparator: "\n"
pattern: ".+" # Log all URL patterns.
# Ship audit logs to SIEM.
# /etc/filebeat/filebeat.yml
filebeat.inputs:
- type: log
paths:
- /var/log/jenkins/audit.log
fields:
log_type: jenkins_audit
fields_under_root: true
Alert on: admin login from unexpected IP; script console access; credential read via API; plugin installation without change ticket.
Step 8: Telemetry
jenkins_builds_total{job, result} counter
jenkins_build_duration_seconds{job} histogram
jenkins_plugins_installed_total{} gauge
jenkins_plugins_with_updates_total{} gauge
jenkins_credential_access_total{credential, job} counter
jenkins_failed_logins_total{user} counter
jenkins_agent_pod_start_seconds{} histogram
jenkins_queue_size{} gauge
Alert on:
jenkins_failed_logins_totalspike — credential stuffing or brute-force against Jenkins login.jenkins_plugins_with_updates_total> 0 with security advisory tag — unpatched plugin with known CVE.- Admin account used outside business hours — potential compromised admin credential.
- Script console accessed (audit log event) — every use requires post-hoc review.
- Agent pod running > 1 hour — potential runaway build or malicious long-running process.
Expected Behaviour
| Signal | Default Jenkins | Hardened Jenkins |
|---|---|---|
| Anonymous API access | All jobs readable | Authentication required; 401 for anonymous |
| Credential exfiltration via script console | All secrets readable by admin | Script console restricted; Vault integration means secrets never in Jenkins store |
| Build with Docker socket | --privileged agent has host root |
Kubernetes ephemeral pods; no host mounts; no Docker socket |
| Plugin CVE exploitation | Unpatched plugins loaded indefinitely | Weekly audit; version-pinned plugins; security advisories monitored |
| JNLP rogue agent | Can connect from any network | JNLP disabled; WebSocket agents require Jenkins auth |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Ephemeral Kubernetes agents | Fresh environment per build; no cross-build contamination | Pod startup time (20-60s); requires Kubernetes | Pre-warm pod pool; use spot/preemptible nodes for cost |
| External credential store (Vault) | Secrets never at rest in Jenkins | Vault dependency; more complex setup | Vault HA deployment; break-glass procedure for Vault outage |
| Plugin version pinning | Prevents untested updates from breaking builds | Manual effort to update pins | Automate via Renovate PR; weekly update batch |
| WebSocket agents (no JNLP port) | Eliminates 50000 attack surface | Requires Jenkins 2.289+ and modern agent image | Upgrade path well-documented; no functional regression |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| OIDC provider unavailable | Nobody can log into Jenkins | Jenkins UI returns 503 | Keep break-glass admin account in secrets manager; restore OIDC |
| Vault unreachable | Builds fail with credential retrieval error | Build failure alert | Vault HA should prevent; cache credentials with short TTL |
| Agent pod OOMKilled | Build fails mid-run | jenkins_build_duration_seconds spike then failure |
Increase pod memory limit; add memory monitoring to build |
| Plugin update breaks build | Pipeline fails after weekly plugin update | Build failure alert | Pin plugin version; roll back via JCasC and restart |
| CSRF crumb validation failure | API clients get 403 on POST | API error logs | Ensure API clients include crumb token; use API token auth which bypasses crumb |