Workload API tokens can be exchanged for persistent unscoped user tokens
The generic-token branch discarded workload claims before the parent token expired
CVSS 3.1 base score
HighVector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N
Summary
Source
build_runner_environment()
src/zenml/zen_server/pipeline_execution/utils.py:1089
Sink
api_token()
src/zenml/zen_server/routers/auth_endpoints.py:549
Code running inside a ZenML pipeline workload could exchange its run-bound token for a generic user token that remained valid after the workload ended, preserving the issuing principal's control-plane RBAC authority for up to the generic-token lifetime.
Root cause. The api_token() generic branch returned before the confinement logic used for workload-token issuance. It called generate_access_token() with only user_id and expires_in, silently dropping schedule_id, pipeline_run_id, and deployment_id claims from the authenticated parent.
Remediation evidence. ZenML opened PR #5152 five days after the disclosure and merged it on 28 August 2026. The PR is an exact remediation: it prevents a scoped workload credential from being exchanged for an unscoped token and adds targeted tests.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- Low
- Privileges requiredPR
- Low
- User interactionUI
- Required
- ScopeS
- Unchanged
- ConfidentialityC
- High
- IntegrityI
- High
- AvailabilityA
- None
Metric values as published in the disclosure vector. Meters show how far each value raises exposure.
Source-to-sink trace
- Source · attacker-controlledsrc/
zenml/ zen_server/ pipeline_execution/ utils.py:1089 build_runner_environment()
- Step 01src/
zenml/ zen_server/ pipeline_execution/ utils.py:1089 ZenML injects a signed API token carrying a run, schedule, or deployment binding into the workload environment.
python environment["ZENML_STORE_API_TOKEN"] = generate_access_token( user_id=user.id, pipeline_run_id=run.id ).access_token - Step 02src/
zenml/ zen_server/ routers/ auth_endpoints.py:549 During the live run, malicious workload code asks the generic-token branch for the maximum allowed lifetime.
python GET /api/v1/api_token?token_type=generic&expires_in=604800 Authorization: Bearer <workload-token> - Step 03src/
zenml/ zen_server/ auth.py:1093 The vulnerable branch issued a new token with only the parent user ID, removing every workload-liveness claim.
python return generate_access_token( user_id=token.user_id, expires_in=expires_in, ).access_token - Sinksrc/
zenml/ zen_server/ routers/ auth_endpoints.py:549 api_token()
Impact
Reported impact
The workload escapes its intended authorization lifetime and continues authenticating as the issuing user or service account after termination. Accessible data and mutations depend on that principal's existing RBAC grants; the flaw does not add permissions beyond them.
Attack surface
ZenML servers that inject API tokens into pipeline workloads and expose the authenticated API-token exchange endpoint.
Preconditions
Attacker-controlled code or a compromised dependency must execute in a live credential-bearing workload. No ZenML signing key, user password, or pre-stolen external token is needed.
Attack path
- 1
Read the server-injected workload token during an active pipeline run.
- 2
Call the generic API-token endpoint with that bearer token.
- 3
Retain the returned unscoped child JWT.
- 4
Wait for the run to finish and the original workload token to fail liveness checks.
- 5
Continue calling the control plane with the child token under the issuing principal's RBAC grants.
Proof of concept
- 01
Environment setup
Run an isolated affected ZenML server and execute a test pipeline step that receives
ZENML_STORE_API_TOKEN. - 02
Target configuration
Use normal authentication and default generic-token lifetime limits. The parent token should carry a real
pipeline_run_id. - 03
Exploit delivery
During the run, request a generic token with the injected credential. End the run, verify the parent is rejected, then call a harmless authorized read endpoint with the child token.
- 04
Expected response
On the vulnerable build, the child request remains authenticated after the parent workload credential has expired through run-liveness enforcement.
- 05
Outcome
PR #5152 rejects the scope-stripping exchange, so a workload-bound parent cannot mint an unscoped generic child token.
Remediation
Guidance
Upgrade to a ZenML release containing PR #5152. Reject generic-token issuance from any workload-scoped parent and preserve every security-relevant binding when a delegated exchange is intentionally supported.
if token_type == APITokenType.GENERIC:return generate_access_token(user_id=token.user_id, expires_in=expires_in)if token_type == APITokenType.GENERIC: if token.schedule_id or token.pipeline_run_id or token.deployment_id: raise AuthorizationException("Scoped tokens cannot mint generic tokens")
Check the upstream record for the project's current remediation status.
Investigate the paths that matter in your codebase.
Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.

