GitHub 安全

如何验证 GitHub Webhook 签名

用更清晰的调试流程验证 GitHub webhook 签名:确认密钥、检查原始 body,并在改动应用逻辑前对比真实请求形态。

要验证 GitHub webhook 签名,请使用你的 webhook 密钥对原始请求体计算 HMAC-SHA256,再将结果与 GitHub 每次投递附带的 X-Hub-Signature-256 请求头对比。若两者在恒定时间比较下一致,则说明负载真实且传输中未被篡改。

GitHub 发送的每个 webhook 都会用你在仓库或组织设置里配置的密钥签名。跳过校验意味着任何发现你端点 URL 的人都能发送伪造负载——触发部署、创建 issue,或修改系统中的数据。验证流程通常不到二十行代码,生产环境没有理由省略。本文说明签名如何工作、提供可复制的 Node.js 代码、梳理最常见失败原因,并展示 HookNexus 如何把调试时间缩短一半。

GitHub webhook 签名如何工作

在 GitHub 创建 webhook 并填写密钥后,GitHub 会在其侧保存该密钥。每次事件触发时,GitHub 将负载序列化为 JSON,用你的密钥作为 key 对该 JSON body 计算 HMAC-SHA256 摘要,并以十六进制字符串形式放在 X-Hub-Signature-256 HTTP 请求头中。

请求头形如:

X-Hub-Signature-256: sha256=a]b1c2d3e4f5...

前缀 sha256= 表示所用算法。接收端要做的事很简单:取请求体的确切字节,用同一密钥运行同样的 HMAC-SHA256,再检查输出是否一致。

X-Hub-Signature(SHA1)与 X-Hub-Signature-256(SHA256)

GitHub 仍会发送较旧的 X-Hub-Signature 头(HMAC-SHA1)。SHA1 对新应用而言已偏弱,GitHub 建议使用 SHA256 变体。验证代码应读取 X-Hub-Signature-256,仅在需要兼容旧配置时才回退到 X-Hub-Signature。本文示例均使用 SHA256。

分步签名验证

第一步 —— 读取原始请求体

最关键的一点是:你必须针对 GitHub 发送的确切字节验证签名。若 Web 框架在读取 body 之前就解析了 JSON,重新序列化的结果可能与原始内容不同——哪怕只差一个空白或键顺序,哈希也会完全不同。

在 Express 中,这意味着在 express.json() 运行之前,在中间件层捕获原始 Buffer。其他框架做法不同,但原则一致:拿到未经改动的 body

第二步 —— 计算 HMAC-SHA256 摘要

拿到原始 body 后,用语言的加密库计算摘要。Node.js 版本如下:

const crypto = require("crypto");

function computeSignature(secret, payload) {
  return (
    "sha256=" +
    crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex")
  );
}

payload 必须是原始字符串或 Buffer——不能是解析后再 JSON.stringify 的对象。secret 与你在 GitHub webhook 设置页填写的值相同。

第三步 —— 用恒定时间比较与请求头对比

不要=== 比较计算出的哈希与请求头。严格相等会在遇到第一个不同字符时立即返回 false,泄露时序信息,攻击者可能逐字符还原合法签名。

应改用 crypto.timingSafeEqual,无论不匹配发生在何处都会比较每一个字节:

const crypto = require("crypto");

function verifySignature(secret, payload, signatureHeader) {
  const expected = computeSignature(secret, payload);

  const sigBuffer = Buffer.from(signatureHeader, "utf8");
  const expBuffer = Buffer.from(expected, "utf8");

  if (sigBuffer.length !== expBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(sigBuffer, expBuffer);
}

在调用 timingSafeEqual 之前必须先做长度检查,因为两个 Buffer 长度不同时该函数会抛错。

第四步 —— 拒绝或接受请求

verifySignature 返回 false,应返回 HTTP 401 或 403 并停止处理。不要在错误输出中打印完整密钥——记录足够排查的上下文(期望前缀、收到的请求头、截断的哈希等),但不要暴露凭据。

若返回 true,则请求可信,继续事件处理逻辑。

完整的验证中间件示例

下面是一个可直接用于生产的 Express 中间件,串联上述步骤。放到 webhook 路由上即可处理原始 body 捕获、签名计算与恒定时间比较:

const crypto = require("crypto");
const express = require("express");

const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;

function verifyGitHubWebhook(req, res, next) {
  const signatureHeader = req.headers["x-hub-signature-256"];

  if (!signatureHeader) {
    return res.status(401).json({ error: "Missing signature header" });
  }

  const rawBody = req.body; // Buffer, captured via express.raw()

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(rawBody)
      .digest("hex");

  const sigBuffer = Buffer.from(signatureHeader, "utf8");
  const expBuffer = Buffer.from(expected, "utf8");

  if (sigBuffer.length !== expBuffer.length) {
    return res.status(401).json({ error: "Signature mismatch" });
  }

  if (!crypto.timingSafeEqual(sigBuffer, expBuffer)) {
    return res.status(401).json({ error: "Signature mismatch" });
  }

  // Parse the verified body into JSON for downstream handlers
  req.body = JSON.parse(rawBody.toString("utf8"));
  next();
}

const app = express();

// Capture the raw body as a Buffer on the webhook route
app.post(
  "/webhooks/github",
  express.raw({ type: "application/json" }),
  verifyGitHubWebhook,
  (req, res) => {
    const event = req.headers["x-github-event"];
    console.log(`Received verified ${event} event`);
    res.status(200).json({ ok: true });
  }
);

app.listen(3000, () => {
  console.log("Listening on port 3000");
});

该中间件的关键点:

  • express.raw({ type: "application/json" }) 只挂在 webhook 路由上,避免影响其他使用 express.json() 的端点。
  • 原始 Buffer 直接传给 createHmac,避免编码往返。
  • 验证通过后,body 只解析一次并写回 req.body,供后续处理器使用。

常见的签名验证失败

签名不匹配是集成 GitHub webhook 时最常遇到的问题之一。根因几乎总属于下面四类之一。

密钥错误

GitHub webhook 设置中的密钥与应用从环境读取的值不一致。复制粘贴错误、.env 里多余的换行、轮换密钥后未同步部署等都很常见。调试时可在启动时打印已加载密钥的前四个字符,上线前务必删掉该日志

在验证前已解析 body

这是最常见原因。若 express.json() 或类似 body 解析器在验证中间件之前运行,req.body 是 JavaScript 对象而非原始字节。再 JSON.stringify 回去,输出很难与原始负载逐字节一致。解决办法是在 webhook 路由使用 express.raw(),验证后再手动解析,与上文中间件一致。

SHA1 与 SHA256 混用

若代码读的是 X-Hub-Signature(SHA1)却计算 SHA256 哈希——或反过来——比较会永远失败。确保请求头名称与 createHmac 中的算法一致。新集成应优先使用 X-Hub-Signature-256sha256

缺少请求头

若完全没有签名头,可能是 GitHub 里未配置密钥,或 GitHub 与服务器之间的代理剥离了该头。检查 GitHub webhook 设置确认已设置密钥,并审查反向代理或 API 网关是否过滤请求头。网络层更多排查步骤见 GitHub webhook 未投递

更快的调试流程

签名验证失败时,常规做法是加日志、重新部署、发测试事件、看日志、再改再试,一轮往往要数分钟。更快的方式是在应用外捕获真实请求,对比代码实际收到的内容,无需反复部署即可定位差异。

HookNexus 提供临时端点 URL,可作为 GitHub 的第二个 webhook 目的地——或调试期间作为主目的地。每次投递都会实时展示,包含完整请求头、原始 body 与确切的 X-Hub-Signature-256

HookNexus 控制台 中看到捕获的请求后,你可以:

  1. 复制原始 body 与签名请求头。
  2. 在本地用同一密钥与 body 运行 computeSignature
  3. 将输出与请求头对比。

若本地计算的哈希与请求头一致,说明密钥正确,问题在框架如何传递 body;若不一致,说明密钥有误。无论哪种情况,通常一步就能锁定根因,而不是多轮部署。

更完整的「捕获—对比」流程见 GitHub webhook 调试HookNexus GitHub 集成文档 中有详细搭建说明。

快速检查清单

每次配置或排查 GitHub webhook 签名验证时,可对照本清单:

  • GitHub 设置中的 webhook 密钥与应用环境变量一致
  • 在任何 JSON 解析中间件运行之前捕获原始请求体
  • 使用 sha256 计算 HMAC,并与 X-Hub-Signature-256 对比
  • 使用 crypto.timingSafeEqual 比较,而不是 =====
  • 调用 timingSafeEqual 前检查 Buffer 长度
  • 验证失败返回 401/403 并终止后续处理
  • 密钥存放在环境变量中,不要硬编码进源码

常见问题

如果不验证 webhook 签名会怎样?

不验证时,任何知道或猜到你 webhook URL 的人都可以伪造 POST 与负载,你的应用会当作来自 GitHub 处理。根据处理器行为——部署、更新数据库、发通知等——可能导致数据损坏、未授权操作或拒绝服务。生产环境务必验证

可以用 SHA1 代替 SHA256 吗?

GitHub 仍会发送使用 HMAC-SHA1 的 X-Hub-Signature 以兼容旧系统,技术上可以。但 SHA1 密码学上已偏弱,GitHub 明确推荐 SHA256。新集成应始终使用 X-Hub-Signature-256。若在维护仍用 SHA1 的旧代码,建议规划迁移到 SHA256——通常只需改请求头名称与 HMAC 算法字符串。

为什么本地签名能通过,生产却失败?

最常见原因是请求体处理方式不同。本地与生产可能使用不同的中间件栈、反向代理或 body 大小限制。解压 gzip、重编码字符或截断大负载的代理会改变你代码看到的字节,从而破坏哈希。可用 HookNexus 捕获到达生产 URL 的确切请求,再与经代理后应用收到的内容逐字节对比。

ping 事件也需要验证签名吗?

需要。ping 是你创建新 webhook 后 GitHub 发送的第一次投递,使用同一密钥签名。验证它能确认双方密钥配置正确。对任何事件类型跳过验证都会给攻击者留下可乘之机。

下一步

完成签名验证后,你的 GitHub webhook 端点已能抵御伪造负载。建议继续: