'HS256', 'typ' => 'JWT'])); $payload = self::base64UrlEncode(json_encode([ 'iss' => rtrim((string) (getenv('APP_URL') ?: ''), '/'), 'sub' => (string) $userId, 'sid' => $sessionId, 'iat' => $now, 'exp' => $now + $ttlSeconds, 'jti' => bin2hex(random_bytes(16)), ], JSON_UNESCAPED_SLASHES)); $signingInput = $header . '.' . $payload; return $signingInput . '.' . self::base64UrlEncode(hash_hmac('sha256', $signingInput, $key, true)); } public static function verify(string $token): ?array { $parts = explode('.', $token); if (count($parts) !== 3) { return null; } [$headerPart, $payloadPart, $signaturePart] = $parts; $header = json_decode(self::base64UrlDecode($headerPart), true); $claims = json_decode(self::base64UrlDecode($payloadPart), true); if (!is_array($header) || ($header['alg'] ?? null) !== 'HS256' || !is_array($claims)) { return null; } try { $key = AppConfig::required('JWT_SIGNING_KEY'); } catch (Throwable $exception) { return null; } if (strlen($key) < 32) { return null; } $expected = self::base64UrlEncode(hash_hmac('sha256', $headerPart . '.' . $payloadPart, $key, true)); if (!hash_equals($expected, $signaturePart) || (int) ($claims['exp'] ?? 0) <= time()) { return null; } if (!ctype_digit((string) ($claims['sub'] ?? '')) || !is_string($claims['sid'] ?? null)) { return null; } return $claims; } private static function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private static function base64UrlDecode(string $value): string { $decoded = base64_decode(strtr($value, '-_', '+/'), true); return $decoded === false ? '' : $decoded; } }