JWT job token memoized with incorrect expiration when generated before job timeout is set

Summary

The JWT job token (encoded_jwt) is memoized on the build object. When pre_assign_runner_checks runs before the job transitions to running, it can trigger token generation via variable serialization code paths. At this point, update_timeout_state hasn't run yet (it runs during pending->running transition), so timeout_value may be nil, resulting in a JWT with only ~6 minutes TTL (60s default + 5min leeway) instead of the job's actual timeout.

This particularly affects jobs with needs: dependencies, as they are more likely to trigger the dependency validation code path.

Steps to reproduce

  1. Create a parent pipeline with a job that triggers a child pipeline:
# .gitlab-ci.yml
setup:
  script: echo "Setup done"

trigger-child:
  trigger:
    include: child.yml
    strategy: depend
  1. Create a child pipeline with a job that has needs: dependencies and a long timeout:
# child.yml
child-job:
  image: ruby
  needs:
    - pipeline: $CI_UPSTREAM_PIPELINE_ID
      job: setup
  script: ruby -e "require 'base64'; puts Base64.encode64(ENV['CI_JOB_TOKEN'])"
  timeout: 1h
  1. Run the pipeline and copy the encoded token output
  2. Decode and check the JWT expiration in a Rails console:
jwt = ::Ci::JobToken::Jwt.decode(Base64.decode64(s)).instance_variable_get(:@jwt)
puts (Time.at(jwt.payload["exp"]) - Time.now) / 60

Example Project

Any project with child pipelines using needs: dependencies from parent pipeline jobs.

What is the current bug behavior?

The JWT token expires in ~6 minutes regardless of the job's configured timeout. This causes:

  • Jobs to fail with 403 errors after ~6 minutes
  • after_script not running
  • Artifacts failing to upload
  • Jobs getting stuck in running state in the UI

What is the expected correct behavior?

The JWT token should have an expiration matching the job's actual timeout plus leeway (e.g., ~66 minutes for a 1-hour timeout job).

Relevant logs and/or screenshots

The token generation is triggered via these code paths:

  • has_valid_build_dependencies? -> processable.simple_variables_without_dependencies -> #scoped_variables
  • secrets_provider_not_found? (EE) -> #variables_encompassing_secrets_configs -> #scoped_variables

And #scoped_variables calls job.try(:token) unconditionally, which memoizes the JWT before the timeout is set.

Relevant code:

Output of checks

This issue was identified on GitLab.com.

Possible fixes

Clear the encoded_jwt memoization in present_build! (which runs after the job has transitioned to running and timeout is set), ensuring the token is regenerated with the correct expiration.

Fixed in !220980 (merged)

Patch release information for backports

If the bug fix needs to be backported in a patch release to a version under the maintenance policy, please follow the steps on the patch release runbook for GitLab engineers.

Refer to the internal "Release Information" dashboard for information about the next patch release, including the targeted versions, expected release date, and current status.

High-severity bug remediation

To remediate high-severity issues requiring an internal release for single-tenant SaaS instances, refer to the internal release process for engineers.

Edited by Hordur Freyr Yngvason