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

  1. Trigger a fresh login/token refresh so a new unexpired token is obtained.
  2. Synchronize server time via NTP to eliminate clock skew with the IDP.
  3. Verify the token refresh/rotation logic in the OAuth client keeps tokens current.
  4. 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

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


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)