公众号分享卡片出去是灰色链接:JS-SDK 自定义分享配置与 invalid signature 排查
上周三下午,运营在群里甩来一张截图:活动页转发到群里,卡片是一行灰色标题加蓝色链接,配图和摘要全没有。前端同事信誓旦旦说 wx.updateAppMessageShareData 调了,后端也说签名接口返回正常。我接手排查,从 wx.error 里挖出 invalid signature,到傍晚定位到根因——测试环境两台机器各自缓存了 access_token,jsapi_ticket 是用不同的 token 换的,导致签名时用的 ticket 与微信侧记录不一致。整个过程踩了五个坑,这篇把它们一次讲清。
一、先分清:灰色链接的两种可能
分享出去是灰链,只有两种情况:

- JS-SDK 没生效:
wx.config校验失败(invalid signature、invalid url domain等),自定义分享从未注册成功,微信兜底用页面标题和首图拼一个默认样式。 - 注册了但时机不对:config 成功了,但
updateAppMessageShareData调用早于wx.ready,或者分享动作发生在注入完成之前。
判断方法很直接:在 wx.error 里打印 err 信息,在 wx.ready 里打一条日志。 ready 没触发就是前者,触发了还灰链就是后者。90% 的灰链案例属于前者,而前者里 90% 是签名问题。
flowchart TD
A[分享出去是灰色链接] --> B{wx.ready 是否触发}
B -- 否 --> C{wx.error 报什么}
C -- invalid signature --> D[签名链路排查]
C -- invalid url domain --> E[检查 JS 安全域名配置]
C -- 没有报错 --> F[检查 wx.config 参数拼写]
B -- 是 --> G[检查分享注册时机]
G --> H[updateXxxShareData 必须在 ready 回调里或之后调用]
D --> I[核对 URL / ticket / 算法]
I --> J[修复后重新验证]
二、invalid signature 的完整排查路径
invalid signature 的含义是:你传给 wx.config 的 signature,与微信服务端按同样参数算出来的值对不上。它是一个纯服务端比对面,排查只需要盯住四个变量:ticket、URL、noncestr、timestamp。其中 timestamp 和 noncestr 是你随手生成的,几乎不会错,所以真正的雷区在 ticket 和 URL。
2.1 第一步:确认签名 URL 与当前页面 URL 严格一致
签名算法里的 url 参数,必须是发起分享时浏览器地址栏的完整 URL,去掉 # 及其后部分,不做任何 encode。这是整个链路里出错率最高的一环。
- Android 微信里,
location.href.split('#')[0]拿到的就是当前页 URL,直接用没问题。 - iOS 微信有一个历史悠久的坑:SPA(Single Page Application)路由推入新页面后,iOS 的微信初始化 config 时用的 URL 仍是首次进入应用时的那个 URL,而不是地址栏当前值。也就是说,用户从
/home推到/activity,你拿/activity去签名,iOS 上必挂invalid signature。 - 解决办法是 iOS 端用进入应用那一刻记录的 URL 去请求签名。常见做法是在应用入口处把
window.location.href.split('#')[0]存到全局变量或 sessionStorage,iOS 走这份,Android 走实时的。
// 伪代码:区分平台取签名 URL
// 这段逻辑建议放在应用入口文件里,只执行一次
var initUrl = window.location.href.split('#')[0]; // 入口处记录一次
// 记录时机要早于任何路由跳转,否则拿到的就不是初始 URL 了
var isIOS = /(iPhone|iPad|iPod)/i.test(navigator.userAgent);
// iOS 微信用初始 URL 签名,Android 用当前 URL 签名
var signUrl = isIOS ? initUrl : window.location.href.split('#')[0];
// 把 signUrl 存到全局,供签名请求与 wx.config 共用
// 注意:hash 路由的 # 后部分不参与签名,这里统一剥掉
window.__SIGN_URL__ = signUrl;
还有一类隐蔽情况:页面 URL 带动态参数,后端签名时用的 URL 是运营在后台手填的,和线上实际带参 URL 差一个字符都不行。签名 URL 必须由前端实时取、实时传给签名接口,不要在服务端写死。
2.2 第二步:确认 jsapi_ticket 的获取与缓存
jsapi_ticket 通过 access_token 换取,官方文档明确要求两者都要全局缓存,ticket 有效期 7200 秒,且有每日获取次数限制。这个环节的典型事故:
- 多实例不共享缓存:两台机器(或容器重建后)各自请求
access_token,后一次请求会使前一次的 token 失效吗?不会,access_token新旧并存一段时间,但如果你拿一台机器新换的 token 去取 ticket,而另一台机器用旧 token 签名,两边 ticket 不一致,签名就废了。我们上次的事故正是如此——测试环境 node 进程和 Python 脚本各缓存了一份。 - 刷新后未同步:token 到期刷新时,ticket 没有跟着刷新,服务还在拿过期 ticket 签名,报错同样是
invalid signature(有些时段报-4001 invalid ticket,注意区分)。
正确姿势:token 和 ticket 存在共享存储(Redis / 数据库),带过期时间,取之前先读缓存。
sequenceDiagram
participant FE as H5 前端
participant SRV as 签名接口
participant RDS as Redis 缓存
participant WX as 微信服务端
FE->>SRV: GET /wx/sign?url=当前页URL
SRV->>RDS: 读 jsapi_ticket
alt 缓存命中且未过期
RDS-->>SRV: 返回 ticket
else 缓存失效
SRV->>RDS: 读 access_token
SRV->>WX: GET /cgi-bin/ticket/getticket?type=jsapi
WX-->>SRV: 返回新 ticket
SRV->>RDS: 写回缓存(过期 7000 秒)
end
SRV->>SRV: 按 key=value 拼串 + sha1
SRV-->>FE: 返回 appId / timestamp / nonceStr / signature
FE->>WX: wx.config 提交四个参数
WX-->>FE: 校验通过触发 wx.ready
2.3 第三步:核对签名算法本身
签名串的拼接规则是固定的四段,按 key 的字典序:jsapi_ticket=...&noncestr=...×tamp=...&url=...,然后做 SHA1。注意:
- 拼接串里的 url 不做 URL encode,保持原样。
- 四个 key 的顺序是字典序,不能按你传参的顺手顺序来。
- SHA1 输出十六进制小写。
三、服务端签名接口实现
3.1 Node.js 版(Express + Redis)
依赖与环境:Node.js 18+,express 4.x,ioredis 5.x,axios 1.x。
// server.js — 公众号 JS-SDK 签名接口
// 生产环境务必通过环境变量注入敏感配置,别硬编码
const express = require('express'); // Web 框架
const axios = require('axios'); // HTTP 客户端
const crypto = require('crypto'); // 内置模块,算 sha1 用
const Redis = require('ioredis'); // 缓存 token 与 ticket
const redis = new Redis(process.env.REDIS_URL); // 单例 Redis 连接
const app = express();
const APP_ID = process.env.WX_APPID; // 公众号 AppID,从环境变量读取
const APP_SECRET = process.env.WX_SECRET; // AppSecret 绝不能写进代码仓库
// 带 Redis 缓存的 access_token 获取
// 所有实例都从这里取 token,保证全局一致
// 千万不要在请求处理过程中现取 token,限频会撞墙
async function getAccessToken() {
const cached = await redis.get('wx:access_token');
if (cached) return cached; // 命中缓存直接返回
const url = 'https://api.weixin.qq.com/cgi-bin/token';
// grant_type 固定为 client_credential
const { data } = await axios.get(url, {
params: { grant_type: 'client_credential', appid: APP_ID, secret: APP_SECRET },
});
// 官方失败时返回 errcode 而不是 access_token,必须显式判断
if (!data.access_token) throw new Error('token 获取失败: ' + JSON.stringify(data));
// 缓存 7000 秒,比官方 7200 留出余量
await redis.set('wx:access_token', data.access_token, 'EX', 7000);
return data.access_token;
}
// 带 Redis 缓存的 jsapi_ticket 获取
// ticket 用 token 换,token 失效会导致换票失败,注意顺序
async function getJsapiTicket() {
const cached = await redis.get('wx:jsapi_ticket');
if (cached) return cached; // 多实例共享,避免各自换票
const token = await getAccessToken();
const { data } = await axios.get(
'https://api.weixin.qq.com/cgi-bin/ticket/getticket',
{ params: { access_token: token, type: 'jsapi' } }
);
// type 固定为 jsapi,别误写成 wx_card
if (data.errcode !== 0) throw new Error('ticket 获取失败: ' + JSON.stringify(data));
// ticket 有效期同样按 7200 秒下发,缓存时长留余量
await redis.set('wx:jsapi_ticket', data.ticket, 'EX', 7000);
return data.ticket;
}
// 签名接口:前端把当前页 URL 原样传上来
app.get('/wx/sign', async (req, res) => {
try {
const rawUrl = req.query.url; // 完整 URL,含协议与端口
// 前端已 encode 传输,Express 会自动解码一次
const url = rawUrl.split('#')[0]; // 双保险:再去一次 hash
const ticket = await getJsapiTicket();
const nonceStr = Math.random().toString(36).slice(2, 18);
const timestamp = Math.floor(Date.now() / 1000); // 必须秒级,不是毫秒
// 签名串的拼接顺序是定死的,别按自己习惯来
// 拼好之后做一次 SHA1,输出小写十六进制
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
// 四个 key 依次是 ticket、noncestr、timestamp、url
// url 参数保持原样,不做任何 encode
// sha1 输出十六进制小写,微信要求的就是这个形态
const signature = crypto.createHash('sha1').update(raw, 'utf8').digest('hex');
// 排查期建议把这四个值和 raw 串打进日志,与微信侧比对
// 线上稳定后可降级为只记 signature 和 url
res.json({ appId: APP_ID, timestamp, nonceStr, signature });
} catch (e) {
// 失败时把上游报错透出,别吞掉
res.status(500).json({ error: e.message });
}
});
app.listen(3000, () => console.log('sign server on :3000'));
// 上线前自查:AppSecret 走环境变量、Redis 地址走内网、接口加访问频率限制
3.2 Python 版(FastAPI)
依赖与环境:Python 3.10+,fastapi 0.110+,uvicorn,httpx,redis 5.x(异步客户端)。
# sign_server.py — 公众号签名接口(FastAPI 版)
# 启动方式:uvicorn sign_server:app --host 0.0.0.0 --port 8000
import hashlib # 标准库,算 sha1
import secrets # 生成 nonceStr
import time # 秒级时间戳来源
import httpx # 异步 HTTP 客户端
from fastapi import FastAPI
from redis import asyncio as aioredis
app = FastAPI()
# decode_responses=True 让 get 返回 str 而不是 bytes
rds = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
APP_ID = "wx1234567890abcdef" # 换成你的 AppID
APP_SECRET = "生产环境请从环境变量或配置中心读取"
# token 与 ticket 的 key 命名保持全局统一
# 建议封装成独立模块,供签名接口和其他微信接口复用
# 多服务共用一个 Redis 时更要注意,避免两套缓存并存
async def get_token() -> str:
cached = await rds.get("wx:access_token")
if cached:
return cached # 缓存命中,避免频繁调用官方接口
url = "https://api.weixin.qq.com/cgi-bin/token"
params = {"grant_type": "client_credential", "appid": APP_ID, "secret": APP_SECRET}
async with httpx.AsyncClient() as c:
resp = (await c.get(url, params=params)).json()
# 官方失败时返回 errcode 字段,这里显式校验
if "access_token" not in resp:
raise RuntimeError(f"token 失败: {resp}")
# 过期时间比官方 7200 秒留 200 秒余量
await rds.set("wx:access_token", resp["access_token"], ex=7000)
return resp["access_token"]
async def get_ticket() -> str:
cached = await rds.get("wx:jsapi_ticket")
if cached:
return cached # ticket 同样必须全局缓存
token = await get_token()
url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
# type=jsapi 是网页分享用的票,wx_card 是卡券,别拿混
params = {"access_token": token, "type": "jsapi"}
async with httpx.AsyncClient() as c:
resp = (await c.get(url, params=params)).json()
if resp.get("errcode") != 0:
raise RuntimeError(f"ticket 失败: {resp}")
await rds.set("wx:jsapi_ticket", resp["ticket"], ex=7000)
return resp["ticket"]
@app.get("/wx/sign")
# url 通过 query 传入,长度超出浏览器限制时可改 POST
async def sign(url: str):
# FastAPI 会自动完成 query 参数解码
url = url.split("#")[0] # 去掉 hash 部分,与前端约定保持一致
ticket = await get_ticket()
nonce = secrets.token_hex(8) # 16 位随机串
ts = int(time.time()) # 秒级时间戳
# 拼接串四个 key 必须按字典序:ticket、noncestr、timestamp、url
# 顺序错一个字符,签名就完全对不上
raw = f"jsapi_ticket={ticket}&noncestr={nonce}×tamp={ts}&url={url}"
# hexdigest 输出小写十六进制,与官方要求一致
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
# 排查期可临时把 raw 串也返回,用官方校验页比对
return {"appId": APP_ID, "timestamp": ts, "nonceStr": nonce, "signature": signature}
两份实现的核心逻辑一致:先读缓存,缺了再换,换完写回。签名本身只是十行以内的字符串拼接加一次 SHA1,没有任何玄学,错了一定是四个输入错了。
四、前端接入与排查代码
依赖与环境:JSSDK 版本 1.6.0(支持 updateAppMessageShareData / updateTimelineShareData),页面通过 https://res.wx.qq.com/open/js/jweixin-1.6.0.js 引入。
// share.js — H5 页面分享注入
// 依赖 jweixin-1.6.0.js,低于 1.4 的版本没有 updateXxxShareData 接口
async function setupWxShare(shareData) {
// 取签名 URL:iOS 与 Android 逻辑不同,见 2.1 节
const isIOS = /(iPhone|iPad|iPod)/i.test(navigator.userAgent);
const signUrl = isIOS
? window.__INIT_URL__ // 入口页记录的初始 URL
: window.location.href.split('#')[0]; // Android 用实时 URL
// 传给签名接口前 encode 一次,防止参数被截断
const cfg = await fetch('/wx/sign?url=' + encodeURIComponent(signUrl))
.then((r) => r.json()); // 前端 encode 传输,服务端解码后是原文
// config 是同步提交、异步回调,成败都走事件
wx.config({
debug: process.env.NODE_ENV !== 'production', // 测试环境开 debug,会弹每一步结果
appId: cfg.appId,
timestamp: cfg.timestamp, // 直接透传服务端的秒级时间戳
nonceStr: cfg.nonceStr, // 与签名时用的 nonceStr 是同一个
signature: cfg.signature,
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData'],
});
wx.ready(() => {
// 分享给朋友:卡片主样式
wx.updateAppMessageShareData({
title: shareData.title, // 卡片标题,32 字以内展示完整
desc: shareData.desc, // 摘要文案
link: shareData.link, // 点击卡片跳转的 URL,须在安全域名下
imgUrl: shareData.imgUrl, // 卡片图,建议 300x300 正方形
});
// 分享到朋友圈:只展示标题和图,desc 不生效
wx.updateTimelineShareData({ title: shareData.title, link: shareData.link, imgUrl: shareData.imgUrl });
});
wx.error((err) => {
// 上线后必留:把 config 失败原因上报到监控
// invalid signature、invalid url domain 都会在这里出现
reportToMonitor('wx_config_error', err);
});
}
debug: true 时微信会在页面顶部弹出每一步的参数和结果,是本地排查期最好用的工具;上线务必关掉。
五、安全域名与 IP 白名单
签名链路之外,还有两处公众号后台配置会直接导致灰链或接口失败:
- JS 接口安全域名(公众号后台 → 设置与开发 → 公众号设置 → 功能设置):
wx.config的页面域名必须在这里登记,且需要把MP_verify_xxx.txt文件放到域名根目录可访问。没配置报invalid url domain。一个月内每个域名有修改次数限制,改前想清楚。注意这里填的是域名,不带协议不带路径。 - IP 白名单(基本配置页):调用
access_token接口的服务器出口 IP 必须在白名单里,否则gettoken直接返回40164错误码。容器化部署要留意出口 IP 是否固定,弹性伸缩的集群建议走 NAT 网关统一出口。
排查期的 checklist 整理如下,按顺序过一遍,多数 invalid signature 在第三条就能定位:
| 序号 | 检查项 | 常见错误形态 | 检查方式 |
|---|---|---|---|
| 1 | 公众号是否为已认证服务号 | 订阅号没有自定义分享相关 JS 接口权限 | 后台查看账号类型 |
| 2 | JS 安全域名已配置且 verify 文件可访问 | 报 invalid url domain |
浏览器直接访问 txt 文件 |
| 3 | 签名 URL 与当前页 URL 一致(去 # 后) | SPA 路由变化、参数被服务端改写 | 两个 URL 都打日志逐字符比对 |
| 4 | iOS 用初始 URL、Android 用实时 URL | iOS 全量失败、Android 正常 | 按机型分别验证 |
| 5 | access_token 全局缓存且未过期 | 多实例各自取 token | 查 Redis 中的键与 TTL |
| 6 | jsapi_ticket 全局缓存、随 token 刷新 | 过期 ticket 签名 | 调 ticket 接口比对返回值 |
| 7 | 签名串按字典序拼接、url 未 encode | 手拼字符串顺序错 | 服务端打印 raw 串人工核对 |
| 8 | timestamp 为秒级且与微信服务器时差小 | 用了毫秒级时间戳 | 对比本机时间与北京时间 |
| 9 | 服务器出口 IP 在白名单内 | gettoken 返回 40164 |
看签名接口的错误日志 |
| 10 | share link 域名在安全域名下 | 卡片可点开但被微信拦截 | 换测试域名对照验证 |
六、原理与机制剖析:微信为什么这样设计签名
理解机制能少走弯路。JS-SDK 的签名本质上是一套防篡改 + 防重放的组合:
- 防篡改:签名串里绑定了当前页 URL,意味着别人不能把你的签名拷贝到自己的域名下用——URL 对不上,SHA1 就对不上。这也解释了为什么签名 URL 必须和页面 URL 严格一致:微信服务端会拿你 config 传来的 URL 参与同一套运算,两边输入差一个字符,输出天差地别。
- 防滥用:
jsapi_ticket必须用access_token换,而 token 的获取受 AppSecret 和 IP 白名单双重保护。ticket 做成短时效且限频,是防止有人绕过公众号体系批量刷分享能力。 - 无状态校验:
wx.config提交后微信服务端独立复算签名,全程不依赖你先调过什么接口。所以签名错不报在签名接口,而是报在前端 config,这种「错误后置」让很多人在服务端日志里找不到任何异常——服务端确实没异常,错的是喂给算法的输入。
一个容易忽略的细节:wx.config 校验通过后,签名的效力与该次页面加载绑定。SPA 内部路由跳转不产生新的页面加载,理论上不用重新 config;但如果你的 URL 带了会变化的查询参数(比如分享回跳带 code),或刷新了页面,就必须重新走一遍取签名和 config 的流程。保险起见,不少团队在路由每次变化到「可分享页」时都重新注入一次,代价是签名接口的调用量,配合 ticket 缓存完全可接受。
七、效果验证方法
修完之后按这个流程验收,别只在自己手机上看:
- 真机验证分安卓和 iOS 各一台,微信版本更新到当前正式版。iOS 的 URL 差异问题只会在 iPhone 上暴露。
- 验证两个入口:聊天窗口内转发(看
updateAppMessageShareData效果)和朋友圈(看updateTimelineShareData效果)。朋友圈卡片不显示 desc,属正常。 - 用「文件传输助手」当沙箱,反复转发不骚扰别人,截图对比。
- 检查 link 域名:卡片点击后落地页必须能正常打开,且域名在安全域名列表内,否则微信会拦截提示。
- 图片规格:
imgUrl建议 300×300 以上正方形,小于 300px 或长宽比过大时部分机型会取不到图,回退成灰色截图。
改造前后的对照:
| 对比维度 | 改造前(默认灰链) | 改造后(自定义分享卡片) |
|---|---|---|
| 卡片样式 | 单行灰字标题 + 蓝色链接 | 大图 + 标题 + 摘要的图文卡片 |
| 标题来源 | 页面 <title> 原文 |
可定制活动文案,与页面标题解耦 |
| 配图 | 无图或页面截图随机裁切 | 指定 1:1 封面图,视觉可控 |
| 摘要 | 无 | 支持一句活动利益点 |
| 群内点击率 | 基线水平 | 上线当周同渠道明显提升(以自有埋点数据为准) |
| 排查耗时 | 运营反馈后当天定位 | checklist 表格可 10 分钟内过完 |
表格里最后一行的「明显提升」请以自己产品的埋点为准,不同活动的分享点击率差异很大,不要照抄任何外部数字。
参考与延伸
- 微信 JS-SDK 官方说明文档(含签名算法与附录排查指引):https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html
- 获取 access_token 接口说明:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
- 微信网页授权(OAuth2)文档,处理分享回跳带 code 的场景:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
- 公众号接口权限说明(账号类型与接口可用性对照):https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Explanation_of_interface_privileges.html
关键词:公众号开发、微信JS-SDK、自定义分享、invalid signature、签名验证、jsapi_ticket、网页开发、踩坑实录