{"record":{"id":"e6d26b56908a290c","repo":"Leantime/leantime","slug":"jwt-token-could-not-be-decoded","errorCode":null,"errorMessage":"JWT token could not be decoded","messagePattern":"JWT token could not be decoded","errorType":"exception","errorClass":"RuntimeException","httpStatus":null,"severity":"error","filePath":"app/Domain/Oidc/Services/Oidc.php","lineNumber":398,"sourceCode":"    }\n\n    /**\n     * @throws GuzzleException\n     */\n    private function decodeJWT(string $jwt): ?array\n    {\n        [$header, $content, $signature] = explode('.', $jwt);\n\n        $tokenData = json_decode($this->decodeBase64Url($content), true);\n\n        if ($this->trimTrailingSlash($tokenData['iss']) != $this->providerUrl) {\n            $this->displayError('oidc.error.providerMismatch', $tokenData['iss'], $this->providerUrl);\n        }\n\n        $headerData = json_decode($this->decodeBase64Url($header), true);\n\n        if (! isset($headerData['kid'])) {\n            throw new \\RuntimeException('JWT token could not be decoded');\n        }\n\n        $key = $this->getPublicKey($headerData['kid']);\n\n        if ($key === false) {\n            return null;\n        }\n\n        $data = $header.'.'.$content;\n\n        if (openssl_verify($data, $this->decodeBase64Url($signature), $key, $this->getAlgorythm($header)) === 1) {\n            return $tokenData;\n        }\n\n        return null;\n    }\n\n    private function getAlgorythm(string $header): int","sourceCodeStart":380,"sourceCodeEnd":416,"githubUrl":"https://github.com/Leantime/leantime/blob/9a9f49f1008f4782b30f6723c54228f4f992e636/app/Domain/Oidc/Services/Oidc.php#L380-L416","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Decode the failing token's header (explode on '.', base64url-decode segment 0) and confirm whether kid is actually absent.","If the IdP can, enable key rotation / multiple signing keys so it emits kid, or update the IdP version that always includes it.","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).","If the token is a JWE, disable id_token encryption on the provider - Leantime only verifies plain RS256 JWS (getAlgorythm() maps RS256 only).","Patch decodeJWT() to fall back to getPublicKey('') when kid is missing, since getPublicKey() already handles an empty kid against a single JWK (line 459)."],"exampleFix":"// before (app/Domain/Oidc/Services/Oidc.php:397)\nif (! isset($headerData['kid'])) {\n    throw new \\RuntimeException('JWT token could not be decoded');\n}\n$key = $this->getPublicKey($headerData['kid']);\n\n// after: getPublicKey() already matches a single JWK when $kid is empty\n// (line 459: ! isset($kid[0]) || $kid == $key['kid']), so only fail when\n// no key could be resolved at all\n$key = $this->getPublicKey($headerData['kid'] ?? '');\nif ($key === false) {\n    throw new \\RuntimeException('JWT token could not be decoded');\n}","handlingStrategy":"try-catch","validationCode":"$segments = explode('.', $idToken);\n$header = json_decode(base64_decode(strtr($segments[0] ?? '', '-_', '+/')), true);\nif (! is_array($header) || ! isset($header['kid'])) {\n    // do not start the exchange: pin a static certificate or fix the IdP first\n    throw new RuntimeException('IdP tokens carry no kid header; set LEAN_OIDC_CERTIFICATE_FILE or LEAN_OIDC_CERTIFICATE_STRING');\n}","typeGuard":"/** True when the JWT JOSE header exposes a kid usable for JWKS lookup. */\nfunction jwtHeaderHasKid(string $jwt): bool\n{\n    $part = explode('.', $jwt)[0] ?? '';\n    $header = json_decode(base64_decode(strtr($part, '-_', '+/')), true);\n\n    return is_array($header) && isset($header['kid']) && $header['kid'] !== '';\n}","tryCatchPattern":"try {\n    // /oidc/callback handling that reaches Oidc::decodeJWT()\n    $oidc->login();\n} catch (\\RuntimeException $e) {\n    Log::error('OIDC login failed: '.$e->getMessage());\n    // never log the raw token; redirect with a generic message\n    return redirect('/login')->withErrors($e->getMessage());\n}","preventionTips":["Prefer IdPs that publish kid in the JWT header (any provider with key rotation does).","Pin the signing key statically via LEAN_OIDC_CERTIFICATE_STRING / LEAN_OIDC_CERTIFICATE_FILE to bypass JWKS kid matching entirely.","Keep the provider on RS256 - Leantime's getAlgorythm() maps RS256 only.","Test the callback flow after any IdP upgrade that changes token format (e.g. enabling encryption)."],"tags":["oidc","jwt","authentication","sso"],"backgroundTag":"jwt-missing-kid-header","analyzedSha":"9a9f49f1008f4782b30f6723c54228f4f992e636","analyzedAt":"2026-08-21T02:37:38.966Z","schemaVersion":2},"datasetVersion":"2026-08-21T03:17:12.404Z"}