PAYATHON 2026

React Native 中 LiveKit 持续报 invalid token 错误如何解决?

排障赵师傅

结论

Could not establish signal connection: invalid token 表示连接在信令鉴权阶段失败。先检查实际传给 LiveKitRoom 的 token、签发凭据和目标服务器是否匹配。

仅凭这段前端代码,还无法确定唯一原因。代码中有一处隐患:useEffect(..., []) 只在组件挂载时读取 AsyncStorage。新课堂的 token 生成并写入缓存后,当前组件的 livekitToken 不会自动更新,仍可能使用旧 token 连接。

建议先绕过 token 缓存,直接用后端为当前课堂返回的新 token 连接,同时核对项目凭据、有效期和入房权限。

为什么现有代码可能一直使用旧 token?

AsyncStorage 与 React state 相互独立。执行:

await AsyncStorage.setItem('livekitToken', newToken);

不会自动触发:

setLivekitToken(newToken);

如果切换课堂时页面没有卸载,原来的读取逻辑就不会重新执行。如果缓存读取与新 token 请求同时进行,旧缓存还可能晚于新请求返回,覆盖刚更新的 state。

现有的 connect 判断只能排除部分空值:

connect={!!livekitToken && livekitToken !== 'null' && livekitToken !== ''}

它无法判断 token 是否过期、签名是否正确,或是否属于当前课堂。单独保存的 livekitRoom 也没有传入连接逻辑。实际连接哪个房间,由 token 授权的房间名称决定。LiveKit 连接说明

旧 token 如果仍然有效,可能让用户进入旧课堂;如果已过期或不被目标服务器接受,才可能触发鉴权错误。因此,旧 token 值得排查,但不能仅凭这条报错就认定它是根因。

按这个顺序排查

1. 核对实际 serverUrl 与签发凭据

确认运行时使用的 serverUrl 与 PHP 后端使用的 API key、API secret 来自同一个 LiveKit Cloud 项目。自部署环境则要确认目标服务器配置了对应的 key 和 secret。

示例中的 URL 可以脱敏,排查时要核对应用实际连接的地址,并确认没有混用测试环境和生产环境的凭据。

后端通过 cURL 成功调用 LiveKit API,只能说明那次服务端请求通过了鉴权,不能证明发给移动端的 token 具备入房权限。RoomService 调用与参与者入房所需的授权不同,例如 roomList、roomCreate 不能替代 roomJoin。RoomService API 文档

2. 检查前端收到的是否是原始 JWT

token 属性应接收 JWT 字符串本身。检查是否误传了以下内容:

  • 整个响应对象,如 { token: "..." }。
  • 带 Bearer 前缀的字符串。
  • 经过 JSON.stringify(token) 后带有额外引号的字符串。
  • PHP warning、HTML 错误页或被截断的响应。

从解析后的 JSON 中取出 token 字段:

const data = await response.json();
setLivekitToken(data.token);

不要把 JSON.stringify(data.token) 传给组件。JWT 通常由三段组成,以 . 分隔,但格式看起来正确并不代表签名有效。

3. 核对 JWT 字段与签名

在受控的本地环境检查 token,重点核对以下字段:

字段检查内容
iss是否为目标 LiveKit 项目的 API key
sub是否为当前参与者的非空 identity
exp首次连接时是否仍在有效期内
nbf当前是否已经到达允许使用的时间
video.roomJoin是否为布尔值 true
video.room是否为当前课堂对应的 LiveKit 房间名称

video.room 应填写房间名称,不要误填房间 SID。业务课堂 ID 可以作为房间名称,但后端创建房间和签发 token 时必须使用同一套映射。Access tokens & grants

PHP 中的时间字段应使用 Unix 秒级时间戳,例如 time()。也要检查签发服务器的系统时间,刚生成的 token 可能因时钟偏差而尚未生效。

解码 payload 只能查看字段,不能验证签名。验证签名需要使用正确的 secret,同时确认目标 LiveKit 项目认可这组凭据。不要将完整 token 或 API secret 粘贴到公共解码网站,也不要写入公开日志。

PHP 后端签发示例

如果后端已经使用 firebase/php-jwt,可以参考以下签发逻辑。示例假设后端已根据登录用户及课堂权限确定 $roomName 和 $identity,并已完成 Composer 自动加载。

use Firebase\JWT\JWT;

$apiKey = getenv('LIVEKIT_API_KEY');
$apiSecret = getenv('LIVEKIT_API_SECRET');
$serverUrl = getenv('LIVEKIT_URL');

if (!$apiKey || !$apiSecret || !$serverUrl) {
    throw new RuntimeException('Missing LiveKit configuration');
}

$now = time();

$payload = [
    'iss' => $apiKey,
    'sub' => $identity,
    'iat' => $now,
    'nbf' => $now,
    'exp' => $now + 600, // 示例:允许在接下来 10 分钟内首次连接
    'video' => [
        'roomJoin' => true,
        'room' => $roomName,
    ],
];

$token = JWT::encode($payload, $apiSecret, 'HS256');

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

echo json_encode([
    'serverUrl' => $serverUrl,
    'roomName' => $roomName,
    'token' => $token,
], JSON_THROW_ON_ERROR);

600 是示例中的有效期,可按业务需要调整。HS256 与 LiveKit 的签发实现一致,JWT::encode() 的用法见 PHP-JWT 文档及 LiveKit 签发源码。

API secret 只保留在后端。接口应先验证用户是否有权进入该课堂,再签发 token,不要直接信任客户端提交的任意房间名称或参与者身份。

前端在课堂变化时重新获取凭据

排查时可以先停用从 AsyncStorage 恢复 token 的逻辑,每次进入课堂都向后端请求凭据,并将 serverUrl、roomName、token 作为一组状态保存。

requestCredentials(classroomId) 是业务接口封装,不是 LiveKit API。它需要检查 HTTP 状态、携带应用登录凭据,并返回上述 PHP 示例中的 JSON。作为 prop 传入时,要保持函数引用稳定,例如在调用方使用 useCallback。

import React, { useEffect, useState } from 'react';
import { Text } from 'react-native';
import { LiveKitRoom } from '@livekit/react-native';

export function ClassroomLive({
  classroomId,
  requestCredentials,
  micOn,
  camOn,
  children,
}) {
  const [connection, setConnection] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;

    setConnection(null);
    setError(null);

    async function load() {
      try {
        const data = await requestCredentials(classroomId);

        if (
          typeof data.token !== 'string' ||
          !data.token.trim() ||
          data.token === 'null' ||
          typeof data.serverUrl !== 'string' ||
          !data.serverUrl.trim() ||
          typeof data.roomName !== 'string' ||
          !data.roomName
        ) {
          throw new Error('Invalid LiveKit credentials response');
        }

        if (active) {
          setConnection({
            classroomId,
            serverUrl: data.serverUrl,
            roomName: data.roomName,
            token: data.token,
          });
        }
      } catch (err) {
        if (active) setError(err);
      }
    }

    if (classroomId) load();

    return () => {
      active = false;
    };
  }, [classroomId, requestCredentials]);

  if (error) {
    return <Text>获取课堂连接凭据失败,请重试。</Text>;
  }

  if (!connection || connection.classroomId !== classroomId) {
    return <Text>正在准备课堂连接…</Text>;
  }

  return (
    <LiveKitRoom
      key={classroomId}
      serverUrl={connection.serverUrl}
      token={connection.token}
      connect={true}
      audio={micOn}
      video={camOn}
      options={{
        adaptiveStream: true,
        dynacast: true,
      }}
      onError={(err) => console.error('LiveKit error:', err)}
    >
      {children}
    </LiveKitRoom>
  );
}

课堂变化时,这段代码会重新请求凭据,并忽略已失效的旧请求结果。渲染条件会阻止新课堂暂时使用旧课堂的凭据。key={classroomId} 会在切换课堂时重建组件,但无法修复 token 本身的签名或权限错误。

如果仍需使用缓存,应同时记录 token 所属的课堂和有效期,并同步更新 React state。原来的 tokenReady 只表示缓存读取完成,不代表 token 已通过 LiveKit 校验。

如何判断问题出在哪里?

用后端刚返回的 token 和配套的 serverUrl 直接连接一次:

  • 直接连接成功,恢复缓存流程后失败:重点检查旧 state、缓存覆盖,以及课堂切换时的请求顺序。
  • 直接连接也失败:重点核对签发凭据、JWT 字段、时间和目标项目,并结合可用的服务端日志确认拒绝原因。
  • 能进入房间但无法发布音视频:继续检查发布权限和设备权限,此时已进入另一个排查阶段。

adaptiveStream、dynacast 控制媒体传输行为,不负责 token 鉴权。针对当前报错,应优先检查后端签发、接口返回、前端 state 和实际连接参数。

备注:内容仅供参考。