Leantime/leantime · error · RuntimeException

JWT token could not be decoded

Error message

JWT token could not be decoded

What it means

Leantime's hand-rolled OIDC client (app/Domain/Oidc/Services/Oidc.php) splits the id_token on '.', base64url-decodes the JOSE header, and requires a 'kid' (key ID) claim so getPublicKey($kid) can pick the matching signing key from the provider's JWKS endpoint. When the decoded header has no 'kid', signature verification cannot proceed and decodeJWT() throws RuntimeException('JWT token could not be decoded'). Only asymmetric JWS tokens whose header names a key are supported; the code path at line 459 does tolerate an empty kid against a single-entry JWK set, but line 397 rejects the token before ever reaching it.

Source

Thrown at app/Domain/Oidc/Services/Oidc.php:398

    }

    /**
     * @throws GuzzleException
     */
    private function decodeJWT(string $jwt): ?array
    {
        [$header, $content, $signature] = explode('.', $jwt);

        $tokenData = json_decode($this->decodeBase64Url($content), true);

        if ($this->trimTrailingSlash($tokenData['iss']) != $this->providerUrl) {
            $this->displayError('oidc.error.providerMismatch', $tokenData['iss'], $this->providerUrl);
        }

        $headerData = json_decode($this->decodeBase64Url($header), true);

        if (! isset($headerData['kid'])) {
            throw new \RuntimeException('JWT token could not be decoded');
        }

        $key = $this->getPublicKey($headerData['kid']);

        if ($key === false) {
            return null;
        }

        $data = $header.'.'.$content;

        if (openssl_verify($data, $this->decodeBase64Url($signature), $key, $this->getAlgorythm($header)) === 1) {
            return $tokenData;
        }

        return null;
    }

    private function getAlgorythm(string $header): int

View on GitHub (pinned to 9a9f49f100)

Solutions

  1. Decode the failing token's header (explode on '.', base64url-decode segment 0) and confirm whether kid is actually absent.
  2. If the IdP can, enable key rotation / multiple signing keys so it emits kid, or update the IdP version that always includes it.
  3. Pin the key directly by setting LEAN_OIDC_CERTIFICATE_STRING or LEAN_OIDC_CERTIFICATE_FILE, which short-circuits getPublicKey() before any kid logic (Oidc.php:436-441).
  4. If the token is a JWE, disable id_token encryption on the provider - Leantime only verifies plain RS256 JWS (getAlgorythm() maps RS256 only).
  5. Patch decodeJWT() to fall back to getPublicKey('') when kid is missing, since getPublicKey() already handles an empty kid against a single JWK (line 459).

Example fix

// before (app/Domain/Oidc/Services/Oidc.php:397)
if (! isset($headerData['kid'])) {
    throw new \RuntimeException('JWT token could not be decoded');
}
$key = $this->getPublicKey($headerData['kid']);

// after: getPublicKey() already matches a single JWK when $kid is empty
// (line 459: ! isset($kid[0]) || $kid == $key['kid']), so only fail when
// no key could be resolved at all
$key = $this->getPublicKey($headerData['kid'] ?? '');
if ($key === false) {
    throw new \RuntimeException('JWT token could not be decoded');
}
Defensive patterns

Strategy: try-catch

Validate before calling

$segments = explode('.', $idToken);
$header = json_decode(base64_decode(strtr($segments[0] ?? '', '-_', '+/')), true);
if (! is_array($header) || ! isset($header['kid'])) {
    // do not start the exchange: pin a static certificate or fix the IdP first
    throw new RuntimeException('IdP tokens carry no kid header; set LEAN_OIDC_CERTIFICATE_FILE or LEAN_OIDC_CERTIFICATE_STRING');
}

Type guard

/** True when the JWT JOSE header exposes a kid usable for JWKS lookup. */
function jwtHeaderHasKid(string $jwt): bool
{
    $part = explode('.', $jwt)[0] ?? '';
    $header = json_decode(base64_decode(strtr($part, '-_', '+/')), true);

    return is_array($header) && isset($header['kid']) && $header['kid'] !== '';
}

Try / catch

try {
    // /oidc/callback handling that reaches Oidc::decodeJWT()
    $oidc->login();
} catch (\RuntimeException $e) {
    Log::error('OIDC login failed: '.$e->getMessage());
    // never log the raw token; redirect with a generic message
    return redirect('/login')->withErrors($e->getMessage());
}

Prevention

When it happens

Trigger: GET /oidc/callback completes the code exchange, decodeJWT() runs on the returned id_token, and base64url_decode(header) yields JSON without 'kid'. Concretely: an IdP that signs with one static key and omits kid; an encrypted id_token (JWE, 5 dot-separated parts) so explode('.', $jwt) produces garbage segments; an opaque access token being parsed as if it were the id_token; a malformed header that json_decode()s to null.

Common situations: Custom or minimal OIDC providers (single-key realms on older Keycloak, lightweight identity servers) that omit kid; providers issuing encrypted id_tokens; LEAN_OIDC_* env vars pointing at the wrong endpoints so the wrong token gets decoded; provider upgrades that switched the token endpoint response shape.

Related errors


AI-assisted analysis of Leantime/leantime@9a9f49f100 (2026-08-21). Data as JSON: /api/errors/e6d26b56908a290c. Report an issue: GitHub.