jdx/mise · error · eyre::Report

GitHub Actions OIDC token expires too soon

Error message

GitHub Actions OIDC token expires too soon

What it means

When acquiring a GitHub Actions OIDC token, the client parses the JWT exp claim and refuses tokens expiring within 60 seconds (REFRESH_LEEWAY_SECONDS). This prevents sending a token that would already be invalid by the time the cache request lands. The same leeway governs when a cached token is refreshed.

Source

Thrown at crates/mise-cache-core/src/lib.rs:749

                    .client
                    .get(self.request_url.clone())
                    .header(AUTHORIZATION, self.request_token.clone())
                    .send()
                    .await?
                    .error_for_status()?
                    .json()
                    .await?)
            })
            .await
            .map_err(|err| {
                eyre!(
                    "failed to acquire GitHub Actions OIDC token for audience {:?}: {err}",
                    self.audience
                )
            })?;
        let expires_at = jwt_expiry(&response.value)?;
        if expires_at <= now.saturating_add(REFRESH_LEEWAY_SECONDS) {
            bail!("GitHub Actions OIDC token expires too soon");
        }
        let authorization = authorization_header(Some(&response.value))?
            .ok_or_else(|| eyre!("GitHub Actions returned an empty OIDC token"))?;
        *cached = Some(CachedOidcToken {
            authorization: authorization.clone(),
            expires_at,
        });
        Ok(authorization)
    }
}

fn jwt_expiry(token: &str) -> Result<u64> {
    let payload = token
        .split('.')
        .nth(1)
        .ok_or_else(|| eyre!("GitHub Actions returned a malformed OIDC token"))?;
    let payload = URL_SAFE_NO_PAD
        .decode(payload)

View on GitHub (pinned to 9dcfcaa0dc)

Solutions

  1. Synchronize the machine clock (enable NTP) — clock skew is the most common cause
  2. Re-run the job or step: GitHub normally issues tokens valid for well over a minute
  3. Ensure requests to the OIDC endpoint are not cached by any intermediary
  4. In tests, mint the mock JWT with exp = now + 3600 so it clears the 60-second leeway

Example fix

// before (test fixture token already expiring)
let token = test_jwt(unix_timestamp().unwrap()); // exp == now -> "expires too soon"

// after
let token = test_jwt(unix_timestamp().unwrap() + 3600);
Defensive patterns

Strategy: retry

Try / catch

let auth = match credential_call().await {
    Ok(auth) => auth,
    Err(report) if report.to_string().contains("expires too soon") => {
        // usually clock skew or a stale cached token: sync clock / retry the job once
        tokio::time::sleep(std::time::Duration::from_secs(30)).await;
        credential_call().await?
    }
    Err(report) => return Err(report),
};

Prevention

When it happens

Trigger: The runner's system clock running ahead of GitHub's time (skew larger than the token lifetime minus 60s); GitHub or a proxy returning a token whose exp is nearly immediate; a test or mock minting a JWT with exp in the past; a cached OIDC response replayed by an intermediary.

Common situations: Self-hosted runners without NTP synchronization; CI steps that acquire the credential and then stall; corporate proxies in front of ACTIONS_ID_TOKEN_REQUEST_URL that cache responses; unit tests whose fixture tokens have fixed exp timestamps.

Related errors


AI-assisted analysis of jdx/mise@9dcfcaa0dc (2026-08-17). Data as JSON: /api/errors/48fc5d24d1e7914b. Report an issue: GitHub.