---
title: JWT 身份验证
description: 在 docs.json 中启用 JWT 身份验证，为每位用户提供短期会话
---

> **For AI agents:** the complete documentation index is at [llms.txt](https://jamdesk.com/docs/llms.txt). Append `.md` to any page URL for its markdown version.

<Note>
  JWT 身份验证需要付费方案，以及一个[已连接 Git 仓库](https://jamdesk.com/docs/cn/setup/connecting-github)的 Jamdesk 项目。配置位于 `docs.json` 中，因此会随常规构建与部署流程一起生效。
</Note>

如果您的产品已有自己的登录系统，JWT 身份验证可以让您使用该系统保护文档，而不必分发共享密码短语。登录用户点击进入文档时，您的后端会签发一个短期令牌。Jamdesk 验证令牌一次、创建会话，此后访客即可正常浏览。访客不需要 Jamdesk 账户或共享密码。

## 这与密码保护有何不同

[密码保护](https://jamdesk.com/docs/cn/setup/password-protection)为每位访客提供相同的共享密码短语，适用于内部文档、暂存预览或单个合作伙伴受众。JWT 身份验证则按用户区分：每位访客的身份、会话时长和页面访问权限，都来自由您的后端签发的令牌。文档访问权限可以遵循现有的客户账户、方案或角色，而不是使用一个共享密钥。

这两种模式互斥：`auth.password` 和 `auth.jwt` 不能同时启用。如果要从一种模式切换到另一种模式，请参阅下方的[从密码保护迁移](#从密码保护迁移)。

## 设置步骤

### 1. 在 docs.json 中启用 auth.jwt

```json docs.json
{
  "$schema": "https://jamdesk.com/docs.json",
  "name": "Acme Docs",
  "theme": "jam",
  "auth": {
    "jwt": {
      "enabled": true,
      "loginUrl": "https://app.example.com/docs-login",
      "public": ["/changelog/*"]
    }
  }
}
```

`enabled: true` 时必须设置 `loginUrl`，且它必须是绝对的 `https://` URL。未通过身份验证的访客会被重定向到此 URL，并附带 `?redirect=<path>`，以便您的登录流程知道应将访客送回哪里。`public` 是可选的：其中可以填写无需登录即可访问的路径或 glob（`*` 表示一个分段，`**` 表示任意深度）。

### 2. 生成签名密钥

在仪表板中打开 **Project Settings**，找到 **JWT authentication** 卡片。点击 **Generate signing key**。

Jamdesk 会创建一个 Ed25519 密钥对，仅保留*公钥*，并且只显示一次*私钥*。请立即将其复制到密钥管理器中。Jamdesk 永不存储或通过电子邮件发送私钥；如果您丢失私钥，也无法恢复。如果发生这种情况，请轮换密钥。轮换会立即切换密钥，因此请先阅读[轮换签名密钥](#轮换签名密钥)，再点击按钮。

### 3. 提交并重新构建

```bash
git add docs.json
git commit -m "Turn on JWT authentication"
git push
```

构建发布后，网站会保护每个页面。没有有效会话的请求会被重定向到您的 `loginUrl`。

## 集成登录流程

登录用户点击进入文档时，您的后端会签发 JWT，并将浏览器重定向到文档网站的回调 URL，同时将令牌放在 URL 片段中（`#` 后面）。片段不会到达服务器日志或任何反向代理，因为浏览器不会在请求中发送片段。

令牌必须使用 EdDSA 签名（Ed25519，与您在仪表板中生成的密钥匹配），并且其 `exp` 声明应不超过未来约 10 秒。这是握手窗口，不是会话时长。实际会话时长由负载中的 `expiresAt` 字段单独控制（请参阅下方的[负载参考](#负载参考)）。

<CodeGroup>
```typescript TypeScript (jose)
import { SignJWT, importPKCS8 } from "jose";

// Store this in your secret manager. It's the private key Jamdesk showed
// you once when you generated it in Project Settings.
const privateKey = await importPKCS8(process.env.JAMDESK_JWT_PRIVATE_KEY!, "EdDSA");

async function signDocsToken(user: { groups: string[]; apiToken: string }) {
  return new SignJWT({
    host: "acme.jamdesk.app", // or your custom domain, e.g. "docs.example.com"
    expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7-day session
    groups: user.groups,
    apiPlaygroundInputs: {
      header: { Authorization: `Bearer ${user.apiToken}` },
    },
  })
    .setProtectedHeader({ alg: "EdDSA" })
    .setExpirationTime("10s") // handshake window, not session length
    .sign(privateKey);
}

// In your "open docs" route/button handler:
app.get("/docs-login", requireAuth, async (req, res) => {
  const token = await signDocsToken(req.user);
  const redirect = req.query.redirect ?? "/";
  res.redirect(
    `https://acme.jamdesk.app/_jd/auth/callback?redirect=${encodeURIComponent(
      String(redirect)
    )}#${token}`
  );
});
```

```python Python (pyjwt)
import time
from urllib.parse import quote

import jwt  # PyJWT >= 2.4, with the cryptography extra installed
from flask import redirect, request  # or your framework's equivalents

with open("jamdesk_jwt_private_key.pem", "rb") as f:
    PRIVATE_KEY = f.read()

def sign_docs_token(user):
    payload = {
        "host": "acme.jamdesk.app",  # or your custom domain
        "exp": int(time.time()) + 10,  # handshake window, not session length
        "expiresAt": int(time.time()) + 60 * 60 * 24 * 7,  # 7-day session
        "groups": user.groups,
        "apiPlaygroundInputs": {
            "header": {"Authorization": f"Bearer {user.api_token}"},
        },
    }
    return jwt.encode(payload, PRIVATE_KEY, algorithm="EdDSA")

@app.route("/docs-login")
def docs_login():
    token = sign_docs_token(current_user)
    redirect_path = request.args.get("redirect", "/")
    return redirect(
        f"https://acme.jamdesk.app/_jd/auth/callback"
        f"?redirect={quote(redirect_path)}#{token}"
    )
```
</CodeGroup>

<Warning>
  只能在服务器端签发令牌。私钥绝不能传递到浏览器或公开仓库中。持有私钥的任何人都可以为您的文档网站创建会话。
</Warning>

## 重定向流程

1. 访客在没有有效会话的情况下请求受保护页面（例如 `/quickstart`）。Jamdesk 会将其重定向到 `{loginUrl}?redirect=%2Fquickstart`。
2. 您的登录流程对访客进行身份验证（按照您的常规方式），签发 JWT，并将其重定向到 `https://<your-docs-host>/_jd/auth/callback?redirect=%2Fquickstart#<jwt>`。
3. 回调页面在客户端从片段中读取令牌，并将其发送到 Jamdesk 的令牌交换端点。Jamdesk 会验证签名和声明，验证成功后设置已签名的会话 Cookie。
4. 浏览器被重定向到原始目标 `/quickstart`，此时已有有效会话。`redirect` 值会在整个流程中保留，因此访客最终会准确回到最初的位置。

如果您的后端无法确定 `redirect` 值（例如有人直接将登录页面加入书签），请省略该值，Jamdesk 会回退到 `/`。

## 公共页面

某些页面应在无需登录的情况下保持可访问，例如状态页面或公开的更新日志。有三种方式可以将页面标记为公共页面，它们最终都会合并到同一个允许列表中：

**Frontmatter**，一次设置一个页面：

```yaml
---
title: Changelog
public: true
---
```

**导航组**，设置整个部分：

```json docs.json
{
  "navigation": {
    "groups": [
      { "group": "Changelog", "public": true, "pages": ["changelog"] }
    ]
  }
}
```

**显式 glob**，位于 `auth.jwt.public[]` 下：

```json docs.json
{
  "auth": {
    "jwt": {
      "enabled": true,
      "loginUrl": "https://app.example.com/docs-login",
      "public": ["/changelog/*", "/status"]
    }
  }
}
```

将页面标记为公开，开放的只是页面本身。页面上的图片和视频由项目的资源路径提供，而这些路径仍在访问控制之后，因此未登录的访客会看到一个没有配图的公开页面。当公开页面需要这些资源时，请把资源路径加入 `auth.jwt.public[]`：

```json docs.json
{
  "auth": {
    "jwt": {
      "public": ["/changelog/*", "/status", "/_jd/images/changelog/**"]
    }
  }
}
```

请把通配符限制在公开页面实际用到的目录上。资源路径不会做分组校验，因此像 `/_jd/images/**` 这样宽泛的通配符会把站点上的所有图片开放给任何人，包括你用 `groups` 限制的页面里的截图。把公开页面的图片放在单独的目录里，只开放该目录。

## 基于组的访问控制

某些页面只应对特定的已认证用户可见，例如管理员运行手册或仅限企业用户的参考文档。请在页面的 frontmatter 中添加 `groups`：

```yaml
---
title: Admin API Keys
groups: ["admin"]
---
```

访客的会话会携带后端放入 JWT 负载中的 `groups` 数组。如果页面声明了 `groups`，而访客的会话与该列表没有交集，则访客会收到 404，而不是 401 或解锁页面。这是有意设计的：组限制页面不会向组外用户透露自身存在。

以下细节会影响 `groups` 的使用方式：

- 组页面会从站点地图、搜索、AI 聊天和 MCP 中排除，即使用户属于该组也是如此。从这些发现入口中排除页面是构建时决定的，而不是按访客决定的。`admin` 组成员仍可直接打开 `/admin/api-keys`（通过 URL 或内部链接），但该页面不会出现在搜索结果、聊天答案或 `llms.txt` 中。如果您希望受限页面能被其目标受众发现，请从该受众已经可以访问的其他页面链接到它。
- 空的 `groups: []` 表示完全不限制，而不是“任何人都不能查看”。要移除页面的组限制，请完全删除 `groups` 字段，而不是将其设置为空数组。
- 要限制任何人访问页面，请取消发布该页面。没有任何 `groups` 值表示“任何人都不能访问”：组成员关系是累加的，只要存在交集就会授予访问权限。
- 本地化副本会自动继承基础页面的 `groups`，除非翻译页面在其 frontmatter 中声明了自己的 `groups`。翻译受限页面不会意外地变成公共页面。
- Jamdesk 会根据 `navigation.languages`，以及任何包含页面且名称为语言代码的顶层文件夹（如 `fr`、`it`、`cs` 等），确定哪些顶层文件夹是翻译目录。一个仅仅与语言代码同名的文件夹（例如存放 IT 运行手册的 `it` 文件夹）也会被视为翻译目录，其中的页面会继承根页面在相同路径上的 `groups`。这只会增加限制，不会减少限制。如果该行为造成问题，请重命名文件夹。
- `groups` 限制的是页面，而不是页面中嵌入的图片、视频和其他文件。只有受限页面才链接的资源，仍会提供给任何直接请求其 URL 的已登录访客，无论其会话携带哪些组。资源 URL 与仓库中的文件路径一致，因此像 `images/admin/sso-config.png` 这样的名称很容易被猜到。不希望每一位登录读者都看到的内容，请不要放进文档仓库。
- 侧边栏、标签页、面包屑以及上一页/下一页链接会按访客进行过滤。访客所属组无法访问的页面会被排除；最终为空的组或标签页也会一并排除，因此受限部分的名称不会显示给组外用户。此过滤发生在请求时，与上文适用于所有人的构建时排除机制相互独立。
- 请保持组名称简短。组会存储在会话 Cookie 中：每个会话最多 32 个组，每个组最多 64 个字符。超过任一限制时，列表不会被截断；Jamdesk 会以 401 拒绝整个令牌，并且不会授予会话。

## API playground 预填充

如果您的文档包含 [API playground](https://jamdesk.com/docs/cn/api-reference/playground)，可以为已登录访客预填充内容，使其无需粘贴自己的 API 密钥。请在 JWT 负载中加入 `apiPlaygroundInputs`：

```json
{
  "host": "acme.jamdesk.app",
  "apiPlaygroundInputs": {
    "header": { "Authorization": "Bearer sk_live_user_specific_token" },
    "query": { "org_id": "acme-corp" },
    "path": { "workspace_id": "ws_123" }
  }
}
```

- `header.Authorization` 会预填充 playground 的身份验证字段。如果存在 `Bearer ` 前缀，系统会自动移除。
- `query` 和 `path` 会预填充当前端点中名称匹配的参数。
- 不支持 `server` 和 `cookie` 部分。只会应用 `header`、`query` 和 `path`。
- 预填充不会覆盖访客已经在 playground 中输入的值。

## 负载参考

| 字段 | 必填 | 描述 |
|---|---|---|
| `host` | 是 | 必须与请求主机完全匹配（不区分大小写）：您的 `*.jamdesk.app` 子域名或自定义域名。为一个主机签发的令牌，在其他主机上会被拒绝。 |
| `expiresAt` | 否 | 生成的*会话*应在何时到期的 Unix 时间戳（秒）。最长 30 天；省略时默认为 7 天。它与令牌自身的短期 `exp` 声明相互独立。 |
| `groups` | 否 | 会话应携带的组名称数组，最多 32 个条目，每个条目最多 64 个字符。超过任一限制时，整个令牌会被拒绝（401，不创建会话），而不是截断列表。 |
| `apiPlaygroundInputs` | 否 | API playground 的预填充值。序列化大小上限为 2KB。如果超出限制，该字段会被静默丢弃，会话仍会授予。 |

## 轮换签名密钥

仪表板卡片上的 **Rotate signing key** 会生成新的密钥对，并像首次生成时一样只显示一次新的私钥。不存在重叠期。大约 15 秒内，旧密钥将停止接受，所有现有会话也会结束。在您的后端使用新密钥签名之前，所有登录都会被拒绝，访客会在登录页面和文档之间来回跳转。

因此操作顺序很重要：

1. 准备好一个部署版本，使其从密钥管理器读取签名密钥，而不是使用硬编码值。
2. 点击 **Rotate signing key**，并复制新的私钥。
3. 更新密钥并部署。后端开始使用新密钥后，登录即可恢复。

如果可以，请选择访问量较低的时间进行轮换，并在点击按钮前通知负责后端部署的人员。

如果您只是想结束所有人的会话，例如笔记本电脑丢失后，请改用 **Revoke sessions**。它会保留密钥，因此后端无需更改；每位访客只需重新登录。

**Clear signing key** 会从 Jamdesk 移除公钥。`auth.jwt` 在 `docs.json` 中仍处于启用状态，因此网站会被锁定：访问者看到的是一个说明该网站为私有的页面，而不是您的登录页面，并且在您生成新密钥之前，任何令牌都无法使用。如果您在生成密钥之前就启用了 `auth.jwt`，情况也是如此。项目 API 密钥仍可用于文档搜索 API 和 MCP。只有在将网站迁移到其他访问模式或准备停用网站时，才应清除密钥。

## 注销

已登录访客会在文档页眉中看到 **Log out** 链接。该链接会将其发送到 `/_jd/auth/logout`，清除会话 Cookie，并重定向到您的 `loginUrl`。如果您希望在自己的应用其他位置提供“退出文档”链接，也可以直接链接到此地址。这是一个不需要请求正文或请求头的普通 `GET` 请求。

退出文档不会让访客退出您的产品。如果您的登录流程会为任何已有应用会话的用户签发令牌，那么访客点击 **Log out** 后再打开文档链接时，会立即重新登录。这通常正是您想要的行为。如果您需要真正退出，请让 `loginUrl` 处理程序检查用户是否明确登录，而不是自动创建令牌；或者将您自己的注销地址同时指向文档注销 URL。

## 身份验证下的功能行为

| 功能 | 行为 |
|---|---|
| `llms.txt` / `llms-full.txt` / sitemap | 与网站其余部分一样受到保护：没有有效会话时无法访问。 |
| 组限制页面 | 从上述所有产物以及搜索和 AI 聊天中排除，与请求会话所属的组无关（请参阅[基于组的访问控制](#基于组的访问控制)）。 |
| 搜索 API（`/_api/search`） | 不受限制。它使用你的 `jd_live_` API 密钥而不是登录 Cookie 进行认证，因此请把密钥保存在服务器端。限制在特定用户组的页面不会出现在结果中。 |
| MCP 服务器（`/_mcp`） | 以 `Authorization: Bearer <key>` 的形式发送项目 API 密钥。没有密钥时，服务器会返回 JSON 格式的 401，而不是跳转到登录页。参见 [MCP 服务器](https://jamdesk.com/docs/cn/ai/mcp-server)。 |
| AI 聊天 | 已登录的访客可以使用。如果会话过期，聊天会提示访客刷新页面并重新登录。 |
| `robots.txt` | 始终公开。搜索引擎可以看到文档网站存在且受到保护，但看不到其内容。 |

## 故障排除

### 我轮换了签名密钥，但旧会话似乎仍然有效

轮换和撤销会在大约 15 秒内生效，而不是立即生效，因为边缘网关会短暂缓存身份验证配置，以确保每次页面请求都很快。仪表板中的 **Rotate** 确实会使所有现有会话失效；请等待最多 15 秒，再将仍然有效的旧会话视为问题。

### 仪表板显示 "Runtime out of sync" 横幅

仪表板与运行时缓存中的签名密钥不一致，通常是因为临时写入失败中断了生成、轮换或清除操作。横幅会说明具体情况：最新密钥可能尚未到达缓存（使用该密钥签名的令牌可能会被拒绝），或者您清除的密钥仍在缓存中（使用该密钥签名的令牌仍会被接受）。每次打开设置页面时，Jamdesk 都会再次检查。如果横幅仍然存在，请点击 **Retry sync**。如果仍然失败，请轮换密钥；如果处于已清除密钥的情况，也可以再次生成并清除。如果横幅显示 Jamdesk 完全无法验证状态，说明检查本身失败；请在运行时恢复可访问后重试。

### 访客即使使用我确定有效的令牌也会收到 401

请检查 `host` 声明是否与实际请求的主机完全一致。如果您的文档既可通过自定义域名（`docs.example.com`）访问，也可通过底层的 `*.jamdesk.app` 子域名访问，则为其中一个主机签发的令牌会在另一个主机上被拒绝：`host` 绑定要求完全匹配且不区分大小写，但不识别别名。请为实际链接到的主机签发令牌；如果两个主机都会被链接，请签发两个变体。

### 我陷入了登录页面和文档网站之间的重定向循环

Jamdesk 的回调路由不会重定向回自身：指向 `/_jd/auth/callback`（或其下方解锁样式页面）的 `redirect` 值会被改写为 `/`，而不会按原值处理。如果仍然出现循环，请检查您的登录流程是否自身循环重定向到文档的 `loginUrl`（例如登录页面找不到文档会话时，立即跳回 `/docs-login`）。文档侧的循环已有防护；循环几乎总是出在登录流程中。

### auth.password 和 auth.jwt 均已启用

这是一个 `config_error`，会阻止构建。请选择其中一种；如果要切换模式，请参阅[从密码保护迁移](#从密码保护迁移)了解安全的操作顺序。

## 安全提示

`apiPlaygroundInputs`（包括您放入其中的任何 Authorization 值）可以由文档网站上运行的 JavaScript 读取，读取入口是为 playground 预填充功能提供支持的会话信息端点。预填充很方便，但不适合存放高权限密钥。

请发送范围限制为访客允许执行的操作、且遵循最小权限原则的用户凭据，绝不要发送组织级管理员密钥。请将放入 `apiPlaygroundInputs` 的任何内容都视为对浏览文档的用户可见，因为事实确实如此。

## 从密码保护迁移

从共享密码切换到 JWT 身份验证无需停机，网站会始终保持受保护状态。请按以下顺序操作：

### 1. 生成 JWT 签名密钥

首先执行此步骤，此时密码保护仍处于启用状态。生成密钥不会改变受保护的内容；在整个过程中密码仍然有效。

### 2. 修改 docs.json 并重新构建

```json docs.json
{
  "auth": {
    "password": { "enabled": false },
    "jwt": { "enabled": true, "loginUrl": "https://app.example.com/docs-login" }
  }
}
```

提交并推送。此构建发布后，保护机制会从密码原子切换为 JWT，不会出现网站未受保护的窗口。现有通过密码解锁的会话会在切换时结束；此后访客将通过您的登录流程进行身份验证。

### 3. 清除密码

确认 JWT 流程端到端运行正常后，返回 **Project Settings** 并清除存储的密码。此时密码已经不起作用（`docs.json` 中的密码模式已关闭），但清除操作会完全移除存储的哈希值。

## 下一步是什么？

<Columns cols={2}>
  <Card title="访问控制概览" icon="shield" href="/cn/setup/access-control">
    对比 JWT 身份验证与密码保护、SSO 以及多项目模式。
  </Card>
  <Card title="密码保护" icon="lock" href="/cn/setup/password-protection">
    共享密码短语替代方案：设置更简单，无需后端集成。
  </Card>
  <Card title="SSO（企业版）" icon="key" href="/cn/setup/sso">
    由身份提供商驱动的企业客户登录方式。
  </Card>
  <Card title="自定义域名" icon="globe" href="/cn/deploy/custom-domains">
    在配置登录流程之前，先将文档放到您自己的域名上。
  </Card>
</Columns>
