跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像AI 生图工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Python

深入理解飞书 Webhook 签名验证:一次踩坑到填坑的完整记录

> 作为一名牛马,我在对接飞书开放平台时遇到了一个看似简单却让人抓狂的问题——签名验证总是失败。经过一番深入研究,我发现这个问题背后隐藏着许多容易被忽视的细节。今天,我想用最通俗的语言,把这段经历记录下来。 故事的开始:一个神秘的签名验证失败 问题现场 那是一个普通的工作日下午,我正在为公司的内部系统对接飞书的事件订阅功能。一切看起来都很顺利: ✅ 应用创建完成 ✅ 事件订阅配置完成 ✅ Webh…

古灵精怪发布于 2026/4/6更新于 2026/7/2498K 浏览
深入理解飞书 Webhook 签名验证:一次踩坑到填坑的完整记录

作为一名牛马,我在对接飞书开放平台时遇到了一个看似简单却让人抓狂的问题——签名验证总是失败。经过一番深入研究,我发现这个问题背后隐藏着许多容易被忽视的细节。今天,我想用最通俗的语言,把这段经历记录下来。

故事的开始:一个神秘的签名验证失败

问题现场

那是一个普通的工作日下午,我正在为公司的内部系统对接飞书的事件订阅功能。一切看起来都很顺利:

  • ✅ 应用创建完成
  • ✅ 事件订阅配置完成
  • ✅ Webhook 地址填写正确
  • ✅ 代码部署上线

但是,当我满怀期待地在飞书后台点击"验证"按钮时,系统日志里出现了这样一行红色的错误:

warn: Mud.Feishu.Webhook.FeishuEventValidator[0] 请求头签名验证失败: 计算 +OGVt6ye......, 期望 bc5b503a...... 

什么?签名验证失败?

我检查了配置文件,密钥都填对了;我检查了代码逻辑,看起来也没问题。但就是验证不通过!

初步分析

让我们先看看日志里的其他信息:

dbug: Mud.Feishu.Webhook.FeishuEventDecryptor[0] 解密成功,结果长度: 489 dbug: Mud.Feishu.Webhook.FeishuEventDecryptor[0] 解密后的JSON数据: {"schema":"2.0","header":{"event_id":"...","token":"fCt8xobp..."}} info: Mud.Feishu.Webhook.FeishuEventDecryptor[0] 事件数据解密成功 - EventType: [contact.department.created_v3] 

有意思的是:

  • ✅ 数据解密成功了
  • ✅ 事件类型识别正确
  • ❌ 但签名验证失败了

这说明什么?说明我的 Encrypt Key(加密密钥)是对的,但签名验证的逻辑肯定哪里出了问题。


飞书 Webhook 的安全机制

在深入问题之前,让我们先理解飞书是如何保护 Webhook 安全的。

两把钥匙的故事

飞书给每个应用配置了两把"钥匙":

飞书应用的两把钥匙

🔑 Verification Token
(验证令牌)

用途:URL 验证请求

格式:随机字符串

示例:fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf

🔐 Encrypt Key
(加密密钥)

用途:数据加密/解密、签名验证

格式:32 位字符串

示例:go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx

简单来说:

  • Verification Token 就像你家的门牌号,用来确认"这是你家"
  • Encrypt Key 就像你家的钥匙,用来"开门"和"验证身份"
飞书发送请求的完整流程

当飞书要给你的服务器发送事件通知时,它会经历这样一个过程:

你的服务器飞书服务器你的服务器飞书服务器步骤 1:准备事件数据步骤 2:使用 Encrypt Key 加密数据步骤 3:生成签名步骤 4:发送 HTTP 请求接收并处理请求{"event_type": "...", "data": {...}}"Ul/tHTDEQkOlKZuqYTS7t+zTb8z/..."timestamp + nonce + key + body↓ SHA-256f2d909fb8a7c3e1d...POST /webhookHeaders:X-Lark-Request-Timestamp: 1768...X-Lark-Request-Nonce: 149323894X-Lark-Signature: f2d909fb...Body: {"encrypt": "Ul/tHTDEQkO..."}

你的服务器需要做什么

收到飞书的请求后,你需要按照相反的顺序验证和处理:

验证通过

验证失败

解密成功

解密失败

收到飞书请求

步骤 1:验证签名 ⚠️

步骤 2:解密数据

❌ 拒绝请求

步骤 3:处理事件

步骤 4:返回响应

✅ 返回成功


我踩过的四个大坑

现在,让我们来看看我在实现签名验证时踩过的坑。如果你也遇到了签名验证失败的问题,很可能就是因为这些原因。

坑 #1:用错了签名算法
❌ 我最初的错误实现
// 我以为飞书用的是 HMAC-SHA256(因为很多平台都用这个)var signString =$"{timestamp}\n{nonce}\n{body}";usingvar hmac =newHMACSHA256(Encoding.UTF8.GetBytes(encryptKey));var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(signString));var signature = Convert.ToBase64String(hashBytes);

为什么错了?

我参考了微信、钉钉等平台的实现,它们大多使用 HMAC-SHA256 算法。但飞书不一样!

✅ 正确的实现
// 飞书使用的是纯 SHA-256 哈希(不是 HMAC)var signString =$"{timestamp}{nonce}{encryptKey}{body}";usingvar sha256 = SHA256.Create();var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));var signature = BitConverter.ToString(hashBytes).Replace("-","").ToLower();

对比表格:

特性HMAC-SHA256SHA-256
是否需要密钥✅ 需要(作为 HMAC 的密钥)❌ 不需要(密钥直接拼接到字符串中)
算法类型消息认证码哈希函数
飞书使用❌✅
微信使用✅❌
坑 #2:签名字符串格式错误
❌ 我最初的错误实现
// 我以为各部分要用换行符分隔(因为看起来更"规范")var signString =$"{timestamp}\n{nonce}\n{body}";

为什么错了?

我想当然地认为,既然是多个部分组成的字符串,应该用某种分隔符。换行符 \n 看起来是个不错的选择。

但实际上,飞书的签名字符串是直接拼接的,而且还要包含 Encrypt Key!

✅ 正确的实现
// 直接拼接,无任何分隔符var signString =$"{timestamp}{nonce}{encryptKey}{body}";

示例对比:

❌ 错误格式(有换行符,缺少 encryptKey): 1768550348 149323894 {"encrypt":"Ul/tHTDEQkO..."} ✅ 正确格式(直接拼接,包含 encryptKey): 1768550348149323894go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx{"encrypt":"Ul/tHTDEQkO..."} 
坑 #3:用错了密钥
❌ 我曾经的困惑
// 我看到解密后的数据里有个 token 字段// {"header":{"token":"fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf"}}// 我想:这个 token 应该就是用来验证签名的吧?var signString =$"{timestamp}{nonce}{verificationToken}{body}";

为什么错了?

这是一个很容易犯的错误。因为:

  1. 解密后的数据里确实有个 token 字段
  2. 这个 token 的值正好是 Verification Token
  3. 名字叫"验证令牌",听起来就应该用来验证

但实际上,签名验证要用 Encrypt Key!

✅ 正确的理解
密钥类型用途在签名验证中
Verification TokenURL 验证请求❌ 不使用
Encrypt Key数据加密/解密 + 签名验证✅ 使用这个

记忆技巧:

  • Verification Token = 门牌号(确认地址)
  • Encrypt Key = 钥匙(开门 + 验证身份)
坑 #4:输出格式不对
❌ 我最初的错误实现
// 我习惯性地把哈希结果转成 Base64var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));var signature = Convert.ToBase64String(hashBytes);// 结果:lL4qIgAs8Kx... (Base64 格式)

为什么错了?

Base64 是很常见的编码方式,我在其他项目中经常这样用。但飞书要的是小写十六进制字符串!

✅ 正确的实现
// 转换为小写十六进制字符串var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));var signature = BitConverter.ToString(hashBytes).Replace("-","").ToLower();// 结果:f2d909fb8a7c... (小写十六进制)

格式对比:

原始哈希值(字节数组): [242, 217, 9, 251, 138, 124, 62, 29, ...] ❌ Base64 编码: 8tkJ+4p8Ph0... ✅ 小写十六进制: f2d909fb8a7c3e1d... 

正确的实现方式

经过一番折腾,我终于搞清楚了正确的实现方式。让我用最清晰的方式展示给你。

完整的验证流程

是

否

否

是

是

否

飞书 Webhook 签名验证流程

第 1 步:提取请求头信息

X-Lark-Request-Timestamp → timestamp

X-Lark-Request-Nonce → nonce

X-Lark-Signature → expectedSignature

第 2 步:读取请求体

原始 JSON 字符串 → body

第 3 步:防重放攻击检查

nonce 是否已使用?

❌ 拒绝请求

timestamp 是否有效?

❌ 拒绝请求

第 4 步:构建签名字符串

signString = timestamp + nonce + encryptKey + body

第 5 步:计算 SHA-256 哈希

SHA256(signString) → hashBytes

第 6 步:转换为小写十六进制

BitConverter.ToString(hashBytes)
.Replace('-', '').ToLower()

第 7 步:固定时间比较

签名是否相等?

✅ 验证通过

❌ 验证失败

C# 完整代码实现
publicasyncTask<bool>ValidateSignature(long timestamp,string nonce,string body,string headerSignature,string encryptKey){try{// ========== 第 1 步:基础验证 ==========// 检查必要参数if(string.IsNullOrEmpty(headerSignature)){ _logger.LogWarning("请求头中缺少 X-Lark-Signature");returnfalse;}if(timestamp ==0||string.IsNullOrEmpty(nonce)){ _logger.LogWarning("时间戳或 nonce 为空");returnfalse;}// ========== 第 2 步:防重放攻击 ==========// 检查 nonce 是否已使用(需要配合 Redis 等缓存实现)if(awaitIsNonceUsed(nonce)){ _logger.LogWarning("Nonce {Nonce} 已使用过,拒绝重放攻击", nonce);returnfalse;}// 验证时间戳(容错 60 秒)if(!IsTimestampValid(timestamp,toleranceSeconds:60)){ _logger.LogWarning("请求时间戳无效: {Timestamp}", timestamp);returnfalse;}// ========== 第 3 步:构建签名字符串 ==========// 注意:直接拼接,无分隔符var signString =$"{timestamp}{nonce}{encryptKey}{body}";// 调试日志(生产环境建议关闭) _logger.LogDebug("签名字符串前 100 字符: {SignStringPrefix}", signString.Substring(0, Math.Min(100, signString.Length)));// ========== 第 4 步:计算 SHA-256 哈希 ==========usingvar sha256 = SHA256.Create();var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));// ========== 第 5 步:转换为小写十六进制字符串 ==========var computedSignature = BitConverter.ToString(hashBytes).Replace("-","").ToLower(); _logger.LogDebug("计算的签名: {ComputedSignature}", computedSignature); _logger.LogDebug("期望的签名: {ExpectedSignature}", headerSignature);// ========== 第 6 步:固定时间比较 ==========// 使用固定时间比较防止计时攻击var isValid =FixedTimeEquals(computedSignature, headerSignature);if(isValid){ _logger.LogInformation("签名验证成功");// 标记 nonce 为已使用awaitMarkNonceAsUsed(nonce);}else{ _logger.LogWarning("签名验证失败");}return isValid;}catch(Exception ex){ _logger.LogError(ex,"验证签名时发生错误");returnfalse;}}/// <summary>/// 固定时间比较,防止计时攻击/// </summary>privatestaticboolFixedTimeEquals(string a,string b){if(a.Length != b.Length)returnfalse;var result =0;for(var i =0; i < a.Length; i++){ result |= a[i]^ b[i];}return result ==0;}/// <summary>/// 验证时间戳是否在有效范围内/// </summary>privateboolIsTimestampValid(long timestamp,int toleranceSeconds){var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);var now = DateTimeOffset.UtcNow;var diff = Math.Abs((now - requestTime).TotalSeconds);return diff <= toleranceSeconds;}
其他语言实现参考
Python 实现
import hashlib import time defvalidate_signature(timestamp, nonce, body, header_signature, encrypt_key):"""验证飞书 Webhook 签名"""# 1. 基础验证ifnot header_signature ornot nonce or timestamp ==0:returnFalse# 2. 时间戳验证(容错 60 秒) current_time =int(time.time())ifabs(current_time - timestamp)>60:returnFalse# 3. 构建签名字符串(直接拼接) sign_string =f"{timestamp}{nonce}{encrypt_key}{body}"# 4. 计算 SHA-256 哈希 hash_obj = hashlib.sha256(sign_string.encode('utf-8'))# 5. 转换为小写十六进制 computed_signature = hash_obj.hexdigest().lower()# 6. 比较签名return computed_signature == header_signature.lower()
JavaScript/Node.js 实现
const crypto =require('crypto');functionvalidateSignature(timestamp, nonce, body, headerSignature, encryptKey){// 1. 基础验证if(!headerSignature ||!nonce ||!timestamp){returnfalse;}// 2. 时间戳验证(容错 60 秒)const currentTime = Math.floor(Date.now()/1000);if(Math.abs(currentTime - timestamp)>60){returnfalse;}// 3. 构建签名字符串(直接拼接)const signString =`${timestamp}${nonce}${encryptKey}${body}`;// 4. 计算 SHA-256 哈希并转换为小写十六进制const computedSignature = crypto .createHash('sha256').update(signString,'utf8').digest('hex').toLowerCase();// 5. 比较签名return computedSignature === headerSignature.toLowerCase();}

安全防护的艺术

签名验证只是安全防护的第一步。要构建一个真正安全可靠的 Webhook 服务,还需要考虑更多细节。

防重放攻击:Nonce 去重机制
什么是重放攻击?

想象这样一个场景:

1. 黑客截获了一个合法的飞书请求 2. 黑客重复发送这个请求 100 次 3. 你的服务器处理了 100 次相同的事件 4. 💥 业务逻辑被重复执行,造成数据混乱 
如何防止?

使用 Nonce(Number used once) 机制:

Redis 缓存服务器客户端Redis 缓存服务器客户端Nonce 去重流程5 分钟后自动过期alt[Nonce 已存在][Nonce 不存在]发送请求 (Nonce: 149323894)提取 Nonce: 149323894EXISTS "feishu:nonce:149323894"返回 true❌ 拒绝请求(重放攻击)返回 false✅ 继续处理SET "feishu:nonce:149323894" "1" EX 300返回处理结果

代码实现(使用 Redis)
publicclassNonceDeduplicator{privatereadonlyIDistributedCache _cache;privatereadonlyILogger<NonceDeduplicator> _logger;publicasyncTask<bool>IsNonceUsed(string nonce){var key =$"feishu:nonce:{nonce}";varvalue=await _cache.GetStringAsync(key);returnvalue!=null;}publicasyncTaskMarkNonceAsUsed(string nonce){var key =$"feishu:nonce:{nonce}";var options =newDistributedCacheEntryOptions{// 5 分钟后自动过期(与时间戳容错时间一致) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)};await _cache.SetStringAsync(key,"1", options); _logger.LogDebug("Nonce {Nonce} 已标记为已使用", nonce);}}
防重放攻击:时间戳验证
为什么需要时间戳验证?
场景 1:网络延迟 飞书发送时间:14:00:00 到达你服务器:14:00:05 ✅ 5 秒延迟,可以接受 场景 2:恶意攻击 飞书发送时间:14:00:00 黑客重放时间:15:00:00 ❌ 1 小时延迟,明显异常 
容错时间设置建议
环境建议容错时间原因
生产环境60 秒平衡安全性和可用性
测试环境300 秒方便调试
开发环境600 秒本地时间可能不准
代码实现
publicboolIsTimestampValid(long timestamp,int toleranceSeconds =60){// 飞书的时间戳是秒级的var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);var now = DateTimeOffset.UtcNow;// 计算时间差(绝对值)var diff = Math.Abs((now - requestTime).TotalSeconds);if(diff > toleranceSeconds){ _logger.LogWarning("时间戳超出容错范围: 请求时间 {RequestTime}, 当前时间 {CurrentTime}, 差异 {Diff}秒", requestTime, now, diff);returnfalse;}returntrue;}
防重放攻击:密钥管理
❌ 危险的做法
// 千万不要这样做!publicclassFeishuConfig{publicconststring EncryptKey ="go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx";publicconststring VerificationToken ="fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf";}

为什么危险?

  • 代码会被提交到 Git 仓库
  • 任何能看到代码的人都能看到密钥
  • 密钥泄露后很难追踪
✅ 安全的做法

方案 1:使用环境变量

// appsettings.json(不包含敏感信息){"FeishuWebhook":{"RoutePrefix":"feishu/webhook"}}// 环境变量(在服务器上配置) FEISHU_ENCRYPT_KEY=go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx FEISHU_VERIFICATION_TOKEN=fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf // 代码中读取var encryptKey = Environment.GetEnvironmentVariable("FEISHU_ENCRYPT_KEY");

方案 2:使用密钥管理服务

// 使用 Azure Key Vaultvar client =newSecretClient(vaultUri,newDefaultAzureCredential());var secret =await client.GetSecretAsync("feishu-encrypt-key");var encryptKey = secret.Value.Value;// 使用 AWS Secrets Managervar client =newAmazonSecretsManagerClient();var request =newGetSecretValueRequest{ SecretId ="feishu/encrypt-key"};var response =await client.GetSecretValueAsync(request);var encryptKey = response.SecretString;
防重放攻击:多应用场景

如果你的公司有多个飞书应用,可以让它们共享一个 Webhook 端点:

多应用配置示例

应用 A
(cli_a98ea7d1a0ba100b)

Encrypt Key:
go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx

Verification Token:
fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf

应用 B
(cli_b12345678901234c)

Encrypt Key:
xY9zAbCdEfGhIjKlMnOpQrStUvWx1234

Verification Token:
gHt9ypcqPLec5zB1VpLKsiOUVbYUaKog

应用 C
(cli_c98765432109876d)

Encrypt Key:
1234AbCdEfGhIjKlMnOpQrStUvWxYz56

Verification Token:
hJu0zqdqQMfd6aC2WqMLtjPVWcZVbLph

配置文件
{"FeishuWebhook":{"MultiAppEncryptKeys":{"cli_a98ea7d1a0ba100b":"go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx","cli_b12345678901234c":"xY9zAbCdEfGhIjKlMnOpQrStUvWx1234","cli_c98765432109876d":"1234AbCdEfGhIjKlMnOpQrStUvWxYz56"},"DefaultAppId":"cli_a98ea7d1a0ba100b"}}
代码实现
privatestringGetEncryptKey(string appId){// 尝试从多应用配置中获取if(_options.MultiAppEncryptKeys.TryGetValue(appId,outvar key)){ _logger.LogDebug("使用应用 {AppId} 的专用密钥", appId);return key;}// 回退到默认密钥if(!string.IsNullOrEmpty(_options.DefaultAppId)&& _options.MultiAppEncryptKeys.TryGetValue(_options.DefaultAppId,outvar defaultKey)){ _logger.LogWarning("未找到应用 {AppId} 的密钥,使用默认密钥", appId);return defaultKey;}// 最后回退到主密钥 _logger.LogWarning("使用主密钥");return _options.EncryptKey;}

问题排查指南

当签名验证失败时,不要慌张。按照这个清单逐项检查,99% 的问题都能找到原因。

排查清单

有问题

有问题

有问题

有问题

正常

正常

正常

正常

有问题

有问题

有问题

有问题

正常

正常

正常

正常

有问题

有问题

正常

正常

有问题

有问题

有问题

正常

正常

正常

签名验证失败

第 1 步:检查密钥配置

Encrypt Key 是否正确?

长度是否为 32 字符?

是否有多余的空格或换行符?

是否使用了 Verification Token?

修正密钥

第 2 步:检查签名字符串构建

是否直接拼接?

顺序是否正确?

body 是否为原始请求体?

是否包含了 Encrypt Key?

修正拼接方式

第 3 步:检查签名算法

是否使用 SHA-256?

输出格式是否为小写十六进制?

修正算法

第 4 步:检查时间戳和 Nonce

时间戳是否在有效范围内?

服务器时间是否准确?

Nonce 是否被误标记?

修正时间相关问题

✅ 问题已解决

重新测试

排查技巧
技巧 1:打印关键信息
_logger.LogDebug("========== 签名验证调试信息 =========="); _logger.LogDebug("Timestamp: {Timestamp}", timestamp); _logger.LogDebug("Nonce: {Nonce}", nonce); _logger.LogDebug("Encrypt Key 前 8 位: {KeyPrefix}", encryptKey.Substring(0,8)); _logger.LogDebug("Body 长度: {BodyLength}", body.Length); _logger.LogDebug("Body 前 100 字符: {BodyPrefix}", body.Substring(0, Math.Min(100, body.Length))); _logger.LogDebug("签名字符串前 150 字符: {SignStringPrefix}", signString.Substring(0, Math.Min(150, signString.Length))); _logger.LogDebug("计算的签名: {ComputedSignature}", computedSignature); _logger.LogDebug("期望的签名: {ExpectedSignature}", headerSignature); _logger.LogDebug("========================================");
技巧 2:使用在线工具验证

你可以创建一个简单的在线工具来验证签名计算:

<!DOCTYPEhtml><html><head><title>飞书签名验证工具</title><scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script></head><body><h1>飞书签名验证工具</h1><label>Timestamp:</label><inputtype="text"id="timestamp"placeholder="1768550348"><br><label>Nonce:</label><inputtype="text"id="nonce"placeholder="149323894"><br><label>Encrypt Key:</label><inputtype="text"id="encryptKey"placeholder="32位密钥"><br><label>Body:</label><textareaid="body"rows="5"placeholder='{"encrypt":"..."}'></textarea><br><buttononclick="calculate()">计算签名</button><h3>结果:</h3><divid="result"></div><script>functioncalculate(){const timestamp = document.getElementById('timestamp').value;const nonce = document.getElementById('nonce').value;const encryptKey = document.getElementById('encryptKey').value;const body = document.getElementById('body').value;// 构建签名字符串const signString = timestamp + nonce + encryptKey + body;// 计算 SHA-256const hash = CryptoJS.SHA256(signString);const signature = hash.toString(CryptoJS.enc.Hex).toLowerCase();// 显示结果 document.getElementById('result').innerHTML =` <p><strong>签名字符串前 100 字符:</strong><br> ${signString.substring(0,100)}...</p> <p><strong>计算的签名:</strong><br> <code>${signature}</code></p> `;}</script></body></html>
技巧 3:单元测试
[Fact]publicasyncTaskValidateSignature_WithCorrectData_ShouldReturnTrue(){// Arrangevar timestamp =1768550348L;var nonce ="149323894";var encryptKey ="go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx";var body ="{\"encrypt\":\"Ul/tHTDEQkOlKZuqYTS7t...\"}";// 手动计算期望的签名var signString =$"{timestamp}{nonce}{encryptKey}{body}";usingvar sha256 = SHA256.Create();var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));var expectedSignature = BitConverter.ToString(hashBytes).Replace("-","").ToLower();// Actvar result =await _validator.ValidateSignature( timestamp, nonce, body, expectedSignature, encryptKey);// Assert Assert.True(result);}
排查速查表
错误现象可能原因解决方案优先级
签名不匹配使用了 HMAC-SHA256改用纯 SHA-256⭐⭐⭐
签名不匹配签名字符串有换行符直接拼接,无分隔符⭐⭐⭐
签名不匹配使用了 Verification Token改用 Encrypt Key⭐⭐⭐
签名不匹配输出格式为 Base64改用小写十六进制⭐⭐⭐
签名不匹配签名字符串缺少 Encrypt Key添加 Encrypt Key⭐⭐⭐
时间戳无效服务器时间不同步同步服务器时间⭐⭐
Nonce 重复Redis 缓存配置错误检查 Redis 连接⭐⭐
解密成功但签名失败密钥配置混乱确认使用正确的密钥⭐⭐

总结与思考

核心要点回顾

让我用一张图总结飞书签名验证的核心要点:

飞书 Webhook
签名验证核心要点

1️⃣ 签名算法

✅ SHA-256

❌ HMAC-SHA256

2️⃣ 签名字符串

✅ 直接拼接

timestamp + nonce + encryptKey + body

❌ 使用换行符分隔

3️⃣ 使用的密钥

✅ Encrypt Key

❌ Verification Token

4️⃣ 输出格式

✅ 小写十六进制

f2d909fb...

❌ Base64 编码

5️⃣ 安全防护

✅ Nonce 去重

✅ 时间戳验证

✅ 固定时间比较

✅ 密钥安全存储

对比其他平台

为了帮助你更好地理解,我整理了几个主流平台的签名验证对比:

平台签名算法密钥类型字符串格式输出格式分隔符
飞书SHA-256Encrypt Keytimestamp+nonce+key+body小写 Hex无
微信SHA-1Token字典序排序后拼接小写 Hex无
钉钉HMAC-SHA256App Secrettimestamp+\n+secretBase64\n
企业微信SHA-256Token字典序排序后拼接小写 Hex无
SlackHMAC-SHA256Signing Secretversion:timestamp:bodyHex:

关键发现:

  • 飞书和微信都用纯哈希(SHA),不用 HMAC
  • 钉钉和 Slack 用 HMAC-SHA256
  • 大部分平台输出十六进制,只有钉钉用 Base64
  • 飞书的特殊之处:签名字符串中包含密钥本身
排查经验总结

经过这次踩坑经历,我总结了几点经验:

💡 经验 1:不要想当然

'我以为飞书应该和微信一样…'
'我觉得应该用换行符分隔…'
'我猜测应该用 Verification Token…'

教训: 每个平台都有自己的实现方式,不要基于其他平台的经验做假设。仔细阅读官方文档是最重要的。

💡 经验 2:日志是你最好的朋友

在调试签名验证问题时,详细的日志帮了我大忙:

// 好的日志示例 _logger.LogDebug("签名字符串: {SignString}", signString); _logger.LogDebug("计算的签名: {Computed}, 期望的签名: {Expected}", computed, expected);// 不好的日志示例 _logger.LogError("签名验证失败");// 没有任何有用信息
💡 经验 3:安全性和可用性的平衡
  • 开发环境:可以放宽限制,方便调试
  • 测试环境:接近生产环境的配置
  • 生产环境:严格的安全策略
var toleranceSeconds = _environment.IsProduction()?60:300;
💡 经验 4:写单元测试

签名验证的逻辑相对独立,非常适合写单元测试:

[Theory][InlineData(1768550348,"149323894","go4kwHmz...","{...}","f2d909fb...")]publicasyncTaskValidateSignature_WithKnownData_ShouldMatch(long timestamp,string nonce,string key,string body,string expected){var result =await _validator.ValidateSignature( timestamp, nonce, body, expected, key); Assert.True(result);}
延伸思考
🤔 为什么飞书不用 HMAC-SHA256?

HMAC-SHA256 是更标准的签名算法,为什么飞书选择了纯 SHA-256?

我的猜测:

  1. 性能考虑:SHA-256 比 HMAC-SHA256 稍快
  2. 实现简单:不需要额外的 HMAC 库
  3. 历史原因:可能是早期设计的遗留

但从安全角度看,HMAC-SHA256 会更好,因为它专门设计用于消息认证。

🤔 为什么要把密钥放在签名字符串里?

这是飞书的一个特殊设计。通常的做法是:

  • HMAC 方式:密钥作为 HMAC 的密钥参数
  • 飞书方式:密钥直接拼接到字符串中

这种方式的优点:

  • 实现简单,不需要 HMAC 库
  • 密钥参与哈希计算,提供了一定的安全性

缺点:

  • 不如 HMAC 标准和安全
  • 容易被误解(很多人会忘记加密钥)
推 荐 资 源

如果你想深入学习,这里有一些推荐资源:

📚 官方文档
  • 飞书开放平台 - 事件订阅
  • 飞书开放平台 - 签名验证
🛠️ 开源项目
  • Mud.Feishu SDK Github
  • Mud.Feishu SDK Gitee
  • 飞书官方 SDK - 多语言官方 SDK

写在最后

从最初的签名验证失败,到最终搞清楚所有细节,这个过程让我深刻体会到:

技术细节决定成败。 一个小小的算法差异、一个字符串格式的不同,都可能导致功能完全无法工作。

希望这篇文章能帮助你:

  • ✅ 理解飞书 Webhook 签名验证的完整机制
  • ✅ 避免我踩过的坑
  • ✅ 快速定位和解决签名验证问题
  • ✅ 构建安全可靠的 Webhook 服务

如果你在实现过程中遇到问题,欢迎在评论区留言讨论。如果这篇文章对你有帮助,也欢迎分享给更多需要的人。

祝你的飞书集成之旅一帆风顺! 🚀


附录:快速参考

A. 签名验证代码模板(C#)
publicasyncTask<bool>ValidateFeishuSignature(HttpRequest request){// 1. 提取请求头var timestamp =long.Parse(request.Headers["X-Lark-Request-Timestamp"]);var nonce = request.Headers["X-Lark-Request-Nonce"].ToString();var signature = request.Headers["X-Lark-Signature"].ToString();// 2. 读取请求体 request.EnableBuffering();var body =awaitnewStreamReader(request.Body).ReadToEndAsync(); request.Body.Position =0;// 3. 构建签名字符串var signString =$"{timestamp}{nonce}{_encryptKey}{body}";// 4. 计算 SHA-256usingvar sha256 = SHA256.Create();var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));var computed = BitConverter.ToString(hash).Replace("-","").ToLower();// 5. 比较签名return computed == signature;}
B. 配置文件模板
{"FeishuWebhook":{"VerificationToken":"从飞书后台获取","EncryptKey":"从飞书后台获取(32位)","RoutePrefix":"feishu/webhook","TimestampToleranceSeconds":60,"EnableRequestLogging":true,"EnableBackgroundProcessing":false}}
C. 术语表
术语英文解释
签名Signature用于验证数据完整性和来源的字符串
哈希Hash将任意长度数据转换为固定长度的算法
HMACHash-based Message Authentication Code基于哈希的消息认证码
NonceNumber used once一次性随机数,用于防重放攻击
时间戳TimestampUnix 时间戳,表示请求发送时间
重放攻击Replay Attack重复发送已截获的合法请求
计时攻击Timing Attack通过测量操作时间来推断信息

目录

  1. 故事的开始:一个神秘的签名验证失败
  2. 问题现场
  3. 初步分析
  4. 飞书 Webhook 的安全机制
  5. 两把钥匙的故事
  6. 飞书发送请求的完整流程
  7. 你的服务器需要做什么
  8. 我踩过的四个大坑
  9. 坑 #1:用错了签名算法
  10. ❌ 我最初的错误实现
  11. ✅ 正确的实现
  12. 坑 #2:签名字符串格式错误
  13. ❌ 我最初的错误实现
  14. ✅ 正确的实现
  15. 坑 #3:用错了密钥
  16. ❌ 我曾经的困惑
  17. ✅ 正确的理解
  18. 坑 #4:输出格式不对
  19. ❌ 我最初的错误实现
  20. ✅ 正确的实现
  21. 正确的实现方式
  22. 完整的验证流程
  23. C# 完整代码实现
  24. 其他语言实现参考
  25. Python 实现
  26. JavaScript/Node.js 实现
  27. 安全防护的艺术
  28. 防重放攻击:Nonce 去重机制
  29. 什么是重放攻击?
  30. 如何防止?
  31. 代码实现(使用 Redis)
  32. 防重放攻击:时间戳验证
  33. 为什么需要时间戳验证?
  34. 容错时间设置建议
  35. 代码实现
  36. 防重放攻击:密钥管理
  37. ❌ 危险的做法
  38. ✅ 安全的做法
  39. 防重放攻击:多应用场景
  40. 配置文件
  41. 代码实现
  42. 问题排查指南
  43. 排查清单
  44. 排查技巧
  45. 技巧 1:打印关键信息
  46. 技巧 2:使用在线工具验证
  47. 技巧 3:单元测试
  48. 排查速查表
  49. 总结与思考
  50. 核心要点回顾
  51. 对比其他平台
  52. 排查经验总结
  53. 💡 经验 1:不要想当然
  54. 💡 经验 2:日志是你最好的朋友
  55. 💡 经验 3:安全性和可用性的平衡
  56. 💡 经验 4:写单元测试
  57. 延伸思考
  58. 🤔 为什么飞书不用 HMAC-SHA256?
  59. 🤔 为什么要把密钥放在签名字符串里?
  60. 推 荐 资 源
  61. 📚 官方文档
  62. 🛠️ 开源项目
  63. 写在最后
  64. 附录:快速参考
  65. A. 签名验证代码模板(C#)
  66. B. 配置文件模板
  67. C. 术语表
  • 免费图片AI生成工具免费生成了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 免费图片视频在线生成30秒,将你的创意变成现实开始设计
  • X/Twitter免费视频下载器免登陆无限额度免费视频解析下载了解详情
  • 100+免费在线小游戏爽一把
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • ClawdBot 语音翻译:Whisper tiny 带口音英语与粤语 WER 对比
  • CentOS 系统定时执行 Python 邮件发送任务的五种方案
  • 如何使用 py-spy 进行 Python 程序调试与死锁分析
  • 从 LLaMA-Factory 微调到高通 NPU 部署:Qwen-0.6B 全链路移植指南
  • CUDA、Python、PyTorch、MMCV、MMDet 版本对应表
  • 攻防世界 Web 题解:SQL 注入与文件包含漏洞分析
  • Git-AI:追踪 AI 生成代码的实用工具
  • 开源机器人 AI 框架 LeRobot 入门与实践
  • OpenClaw 对接飞书机器人常见问题与 Gateway 稳定性排查
  • OpenClaw:实现 AI 直接操控电脑的本地部署指南
  • B 站转型 AI 创新孵化器:开发者生态观察
  • ToDesk ToClaw 评测:零门槛体验 OpenClaw 级 AI 自动化
  • Python 量化交易实盘部署与风险管理实战
  • Gemini 2.5 Pro 技术突破与实战应用深度解析
  • Trae IDE 安装与使用教程
  • Dify Web 前端二次开发:隐藏探索功能与自定义 Logo
  • Windows 系统下 PyCharm IDE 安装与配置指南
  • Agentic Workflow 解析:技术挑战与未来趋势
  • LangChain Agent 详解:原理、类型与实战应用
  • 基于 Cursor 开发 Spring Boot 项目指南

相关免费在线工具

  • curl 转代码

    解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online