BookStackApp/BookStack · error · OidcInvalidTokenException

Token has expired

Error message

Token has expired

What it means

The exp claim was present but the current time has passed exp + 120 seconds of allowed clock skew, so the ID token is expired and validateTokenClaims rejects it. This protects against replaying old tokens; the 2-minute skew window tolerates minor clock drift between client and IdP.

Source

Thrown at app/Access/Oidc/OidcIdToken.php:60

        // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
        // NOTE: Addressed by enforcing a count of 1 above.

        // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
        // is the Claim Value.
        if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
            throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
        }

        // 5. The current time MUST be before the time represented by the exp Claim
        // (possibly allowing for some small leeway to account for clock skew).
        if (empty($this->payload['exp'])) {
            throw new OidcInvalidTokenException('Missing token expiration time value');
        }

        $skewSeconds = 120;
        $now = time();
        if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
            throw new OidcInvalidTokenException('Token has expired');
        }

        // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
        // limiting the amount of time that nonces need to be stored to prevent attacks.
        // The acceptable range is Client specific.
        if (empty($this->payload['iat'])) {
            throw new OidcInvalidTokenException('Missing token issued at time value');
        }

        $dayAgo = time() - 86400;
        $iat = intval($this->payload['iat']);
        if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
            throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
        }

        // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
        // The meaning and processing of acr Claim Values is out of scope for this document.
        // NOTE: Not used for our case here. acr is not requested.

View on GitHub (pinned to 18f8469a1c)

Solutions

  1. Restart the OIDC login flow to obtain a fresh ID token
  2. Sync server clocks with NTP (timedatectl / chrony) to eliminate clock drift
  3. Increase the IdP's ID token lifetime if it is set impractically short
  4. Do not cache or reuse ID tokens across login attempts; use each token immediately after issuance
  5. For testing, generate tokens at request time instead of reusing stored ones

Example fix

// before (test fixture)
$token = buildIdToken(['exp' => 1600000000]); // long past
// after
$token = buildIdToken(['iat' => time(), 'exp' => time() + 300]);
Defensive patterns

Strategy: retry

Validate before calling

$payload = /* decode jwt payload */;
if (!empty($payload['exp']) && time() >= (intval($payload['exp']) + 120)) {
    // token already expired — request a fresh one instead of validating
    return redirect('/oidc/login');
}

Type guard

function tokenIsCurrent(?array $payload, int $skew = 120): bool {
    return isset($payload['exp']) && time() < (intval($payload['exp']) + $skew);
}

Try / catch

try {
    $idToken = OidcIdToken::validate($token, $clientId, $keys);
} catch (OidcInvalidTokenException $e) {
    if (str_contains($e->getMessage(), 'expired')) {
        return redirect('/oidc/login'); // silently restart the flow for a fresh token
    }
    throw $e;
}

Prevention

When it happens

Trigger: validate() called with a token whose exp timestamp is in the past (beyond the 120s skew): user left the OIDC login flow open too long, IdP issues very short-lived tokens, or the server clock is ahead of the IdP clock.

Common situations: Slow login redirect where the user waits minutes before the callback; NTP drift on the BookStack server making it think valid tokens are expired; replaying a captured/hardcoded token in tests; IdP token lifetime configured to a few seconds.

Related errors


AI-assisted analysis of BookStackApp/BookStack@18f8469a1c (2026-09-02). Data as JSON: /api/errors/8059fcdac35829b4. Report an issue: GitHub.