roundcube/roundcubemail · error · RuntimeException
Failed to validate JWT: expired message
Error message
Failed to validate JWT: expired message
What it means
jwt_decode() rejects tokens whose 'exp' claim is in the past. This is a standard JWT freshness check ensuring expired tokens (ID, logout, bearer, refresh) are never trusted.
Solutions
- Trigger a fresh login/token refresh so a new unexpired token is obtained.
- Synchronize server time via NTP to eliminate clock skew with the IDP.
- Verify the token refresh/rotation logic in the OAuth client keeps tokens current.
- Decode the token and compare 'exp' with current time to confirm the diagnosis.
Example fix
// before: replaying stored token
$token = $_SESSION['oauth_token']; // exp in the past
// after: refresh or re-authenticate when expired
if ($expiry <= time()) { $token = $oauth->refresh_token($refresh_token); } Defensive patterns
Strategy: validation
Validate before calling
$claims = json_decode(base64_decode(strtr(explode('.', $token)[1], '-_', '+/')), true);
if (isset($claims['exp']) && time() >= $claims['exp']) { /* token expired: refresh before use */ } Type guard
function tokenIsFresh(array $claims, int $leeway = 30): bool { return !isset($claims['exp']) || time() < ($claims['exp'] - $leeway); } Try / catch
try { $payload = $oauth->jwt_decode($token); } catch (\RuntimeException $e) { if (str_contains($e->getMessage(), 'expired')) { $oauth->request_access_token(); } } Prevention
- Keep server clocks NTP-synchronized with the IDP.
- Refresh access tokens proactively before expiry.
- Clear expired tokens from session storage instead of replaying them.
- Log token expiry times to catch refresh-flow regressions early.
When it happens
Trigger: parse_tokens() -> jwt_decode() where isset($body['exp']) && time() > $body['exp'] — server clock ahead of the IDP's clock, long-idle session replaying an old token, or back-channel logout arriving after token expiry.
Common situations: Clock skew between web server and IDP; stored tokens reused after long inactivity; refresh flow failure leaving an expired access token in place; testing with a deliberately expired token.
Related errors
- Failed to validate JWT: invalid azp value
- Failed to validate JWT: invalid aud value
- Failed to validate JWT: missing aud/azp value
- Failed to validate JWT: issuer mismatch
- OIDC: Handle only logout events
AI-assisted analysis of roundcube/roundcubemail@4b54c2acfb (2026-09-14).
Data as JSON: /api/errors/799bb631466c7191.
Report an issue: GitHub.
Appendix: source
Thrown at program/include/rcmail_oauth.php:462
}
// FIXME depends on body type: ID, Logout, Bearer, Refresh,
if (isset($body['azp']) && $body['azp'] !== $this->options['client_id']) {
throw new \RuntimeException('Failed to validate JWT: invalid azp value');
} elseif (isset($body['aud']) && !in_array($this->options['client_id'], (array) $body['aud'])) {
throw new \RuntimeException('Failed to validate JWT: invalid aud value');
} elseif (!isset($body['azp']) && !isset($body['aud'])) {
throw new \RuntimeException('Failed to validate JWT: missing aud/azp value');
}
// if defined in parameters, check that issuer match
if (isset($this->options['issuer']) && $body['iss'] !== $this->options['issuer']) {
throw new \RuntimeException('Failed to validate JWT: issuer mismatch');
}
// check that token is not an outdated message
if (isset($body['exp']) && (time() > $body['exp'])) {
throw new \RuntimeException('Failed to validate JWT: expired message');
}
$body['header'] = $header;
$this->log_debug('jwt: %s', json_encode($body));
return $body;
}
/**
* Compose a fully qualified redirect URI for auth requests
*
* @return string
*/
public function get_redirect_uri()
{
$url = $this->rcmail->url([]);
View on GitHub (pinned to 4b54c2acfb)