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
- Restart the OIDC login flow to obtain a fresh ID token
- Sync server clocks with NTP (timedatectl / chrony) to eliminate clock drift
- Increase the IdP's ID token lifetime if it is set impractically short
- Do not cache or reuse ID tokens across login attempts; use each token immediately after issuance
- 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
- Run NTP/chrony on the app server to prevent clock-drift false expiries
- Consume ID tokens immediately after callback; never cache across attempts
- Set a sane IdP token lifetime (a few minutes minimum)
- In tests, mint tokens at request time with current iat/exp
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
- Token issue at time is not recent or is invalid
- Token audience value has ' . count($aud) . ' values, Expecte
- Token authorized party exists but does not match the expecte
- Missing token expiration time value
- Missing token issued at time value
AI-assisted analysis of BookStackApp/BookStack@18f8469a1c (2026-09-02).
Data as JSON: /api/errors/8059fcdac35829b4.
Report an issue: GitHub.