简介:微信公众号H5页面在调用分享接口时,后端签名验证是不少开发者容易卡壳的环节。针对这一场景,一份封装好的PHP后端签名验证方案能够直接拿来使用,适合正在开发H5分享功能、需要快速接入微信JS-SDK的前后端开发者。压缩包内仅1个文件,为单个PHP脚本,包体约2KB,结构精简,方便直接放入现有项目并快速定位签名逻辑。从学习热度看,目前已有1737人学习下载,说明该方案在实战中具备一定参考价值。使用者在拿到资源后,只需补充公众号AppID和AppSecret,并部署至可访问HTTPS的服务器即可获取所需签名;脚本将参与签名生成的令牌、时间戳等参数处理过程一并封装妥当,可帮助跳过繁杂的参数拼接与官方文档梳理,把更多精力放在H5分享页面的业务实现上。
1. 微信分享签名验证为什么非要走后端
微信分享卡片能不能正确显示标题、描述和缩略图,关键不在页面里写了什么 meta 标签,而在wx.config的签名是否通过。前端最常见的报错是invalid signature,十次里有八次不是算法不对,而是签名时用的url和当前页面实际地址不一致,另外两次是拿到了过期的jsapi_ticket。把这个签名逻辑放到 PHP 后端独立成一个接口,前端只把当前页面 url 传回来换取签名字段,是前后端分离项目里最稳的做法。这篇给出一套可以直接落地的 PHP 实现:一个类负责缓存access_token和jsapi_ticket,一个接口返回wx.config需要的四个字段,复制到 nginx 站点里就能跑通。
2. 微信后端的签名链路:ticket 获取、参数拼接与 url 边界
2.1 access_token 与 jsapi_ticket:两道会过期的令牌
微信 JS-SDK 的签名原料中,jsapi_ticket不能直接拿到,必须先通过公众号的appid和secret换取access_token,再拿access_token换ticket。这是两道不同的令牌,作用完全不同:
access_token是公众号的全局接口凭证,有效期 7200 秒,官方明确提示要自行缓存,否则频繁调用会被限流。jsapi_ticket是 JS-SDK 专用的临时票据,有效期同样是 7200 秒,生成签名时才用到它。两者都不能出现在前端代码里,一旦暴露,等于把公众号接口的操作权交了出去。
| 关键项 | access_token | jsapi_ticket |
|---|---|---|
| 获取接口 | cgi-bin/token | cgi-bin/ticket/getticket |
| 请求参数 | grant_type=client_credential | access_token与type=jsapi |
| 有效期 | 7200 秒 | 7200 秒 |
| 服务端缓存 | 必须 | 必须 |
| 返回前端 | 禁止 | 禁止,仅作为签名原料 |
后端拿到有效期内的一次性签名结果就够了,原始令牌完全不暴露,前端也就无法绕过签名机制去调用微信接口。
2.2 拼接顺序与 sha1:noncestr 和 nonceStr 别搞混
微信官方给出的签名生成算法分四步:取得jsapi_ticket,生成随机字符串nonceStr,取当前时间戳,然后按固定格式拼接并做sha1哈希。拼接模板如下:
jsapi_ticket={ticket}&noncestr={nonceStr}×tamp={timestamp}&url={url}注意拼接串里写的是noncestr全小写,而 JSON 返回字段名是nonceStr大写 S。这个大小写差异非常容易踩坑,有些人直接从返回 JSON 里复制字段名去拼字符串,结果算出来的signature永远对不上。
这个拼接串没有任何 URL 编码,url必须是页面完整的原始地址,协议、域名、路径、查询参数一个都不能少,同时不能带#锚点。最后的校验方式是微信服务器收到前端wx.config的请求后,用同样的参数自己拼一次再做sha1,一致才放行。
2.3 url 边界:为什么必须 split('#')[0]
签名校验里最容易出问题的就是url前后不一致。前端页面地址https://example.com/path?id=1#/detail,如果签名时传了完整地址,微信那边拿到的却是去掉锚点的地址,签名立刻失效。
规范的取值方式统一用location.href.split('#')[0],后端接口收到后不应该再做urlencode或rawurlencode处理,原样拼接即可。如果页面里有动态参数,必须保证请求签名接口时用的就是用户当前看到的地址。前端传参时用encodeURIComponent只是传输层编码,服务端接受后 PHP 会自动还原成原始 url,这和拼接签名时用的字符串不冲突。
另外不要在服务端自行拼接域名或路径,把前端传来的 url 当不透明字符串处理,能避开绝大多数invalid signature问题。
3. PHP 后端签名验证实现:三个文件组成的下载即用接口
3.1 文件结构与 config.php 参数说明
这套实现不依赖任何框架,纯 PHP 文件就能跑。目录结构如下:
wechat-share/ ├── api.php ├── inc/ │ ├── config.php │ └── WechatShareSigner.php └── cache/config.php只保存公众号基础配置,内容如下:
<?php return [ // 公众号后台 -> 设置与开发 -> 基本配置 中获取 'appid' => 'wx1234567890abcdef', 'secret' => 'your_api_secret_here', // 缓存目录,存放 access_token 与 jsapi_ticket 的 json 文件 // nginx 运行用户(通常是 www-data 或 www)需要可写权限 'cache_dir' => __DIR__ . '/../cache', ];appid和secret是签名链路里仅有的两个私密凭据,务必保证只有服务端能读取。如果你的环境是 Windows 10 下用 nginx 调试,注意cache目录要给 nginx 进程写权限,否则请求会被 PHP 的file_put_contents报错打断。
3.2 获取并缓存 access_token 与 jsapi_ticket
WechatShareSigner.php是核心类,职责是维护令牌缓存、对外提供签名方法。下面是最小可用的完整实现:
<?php class WechatShareSigner { private $appid; private $secret; private $cacheDir; public function __construct(array $config) { $this->appid = $config['appid']; $this->secret = $config['secret']; $this->cacheDir = $config['cache_dir']; if (!is_dir($this->cacheDir)) { mkdir($this->cacheDir, 0755, true); } } // 生成 wx.config 需要的全部字段 public function signature(string $url): array { $ticket = $this->getJsApiTicket(); $nonceStr = $this->createNonceStr(16); $timestamp = time(); return [ 'appId' => $this->appid, 'timestamp' => $timestamp, 'nonceStr' => $nonceStr, 'signature' => self::buildSignature($ticket, $nonceStr, $timestamp, $url), ]; } // 将签名算法抽成静态方法,方便自检脚本单独调用 public static function buildSignature( string $ticket, string $nonceStr, int $timestamp, string $url ): string { $string = "jsapi_ticket={$ticket}&noncestr={$nonceStr}×tamp={$timestamp}&url={$url}"; return sha1($string); } public function getAccessToken(): string { $cacheFile = $this->cacheDir . '/access_token.json'; $data = $this->readCache($cacheFile); // 缓存未过期则直接复用,避免每次请求都打到微信接口 if ($data && $data['expire_at'] > time()) { return $data['access_token']; } $url = 'https://api.weixin.qq.com/cgi-bin/token' . '?grant_type=client_credential' . '&appid=' . $this->appid . '&secret=' . $this->secret; $result = json_decode($this->httpGet($url), true); if (isset($result['errcode']) && $result['errcode'] != 0) { throw new RuntimeException('access_token 获取失败: ' . $result['errcode'] . ' ' . $result['errmsg']); } // 提前 200 秒过期,规避服务端与微信服务器的时间误差 $this->writeCache($cacheFile, [ 'access_token' => $result['access_token'], 'expire_at' => time() + $result['expires_in'] - 200, ]); return $result['access_token']; } public function getJsApiTicket(): string { $cacheFile = $this->cacheDir . '/jsapi_ticket.json'; $data = $this->readCache($cacheFile); if ($data && $data['expire_at'] > time()) { return $data['ticket']; } $accessToken = $this->getAccessToken(); $url = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket' . '?access_token=' . $accessToken . '&type=jsapi'; $result = json_decode($this->httpGet($url), true); if (isset($result['errcode']) && $result['errcode'] != 0) { throw new RuntimeException('jsapi_ticket 获取失败: ' . $result['errcode'] . ' ' . $result['errmsg']); } $this->writeCache($cacheFile, [ 'ticket' => $result['ticket'], 'expire_at' => time() + $result['expires_in'] - 200, ]); return $result['ticket']; } private function createNonceStr(int $length = 16): string { $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; $str = ''; for ($i = 0; $i < $length; $i++) { $str .= $chars[random_int(0, strlen($chars) - 1)]; } return $str; } private function httpGet(string $url): string { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); $response = curl_exec($ch); $errno = curl_errno($ch); curl_close($ch); if ($errno !== 0) { throw new RuntimeException('请求微信接口失败, curl errno: ' . $errno); } return $response; } private function readCache(string $file): ?array { if (!is_file($file)) { return null; } $content = file_get_contents($file); if ($content === false) { return null; } $data = json_decode($content, true); return is_array($data) ? $data : null; } private function writeCache(string $file, array $data): void { file_put_contents($file, json_encode($data), LOCK_EX); } }这段代码有几个关键点需要说明。缓存判断的核心是expire_at > time(),过期才重新请求微信接口;expires_in减去 200 秒是给本地缓存留出时间冗余,防止在将要过期的边缘上频繁刷新。random_int生成随机字符串比mt_rand更安全,签名用的 nonceStr 虽然不参与业务校验,但不建议用固定值。
提示:如果之后项目接入了 Redis,把
readCache和writeCache两个方法替换成get/setex即可,逻辑不用动。
3.3 对外接口 api.php 与响应格式
api.php负责接收前端请求并返回 JSON,代码很短:
<?php header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); require __DIR__ . '/inc/WechatShareSigner.php'; $config = require __DIR__ . '/inc/config.php'; // 前端用 encodeURIComponent 传递完整 url,PHP 会自动解码回原始字符串 $url = $_GET['url'] ?? ''; if ($url === '') { http_response_code(400); echo json_encode(['errcode' => 400, 'errmsg' => 'url 参数不能为空']); exit; } try { $signer = new WechatShareSigner($config); echo json_encode(['errcode' => 0, 'data' => $signer->signature($url)]); } catch (Throwable $e) { http_response_code(500); echo json_encode(['errcode' => 500, 'errmsg' => $e->getMessage()]); }接口只接收一个必填参数url,业务层不用关心access_token和ticket的细节,拿过来直接当黑盒使用。正常响应会包含四个字段,对应前端wx.config的入参:
{ "errcode": 0, "data": { "appId": "wx1234567890abcdef", "timestamp": 1712345678, "nonceStr": "a8Bc3dEfGhIjKlMn", "signature": "5f5b5c6a7b8c9d0e1f2a3b4c5d6e7f8g9h0i1j2k" } }nginx 环境下把整个目录放进站点根目录,访问api.php?url=...即可。鉴权、限流、POST 封装这些属于业务层扩展,这套基础版本只负责把签名做对。
4. 前后端分离接入:wx.config 注入与分享样式签名一致性
4.1 前端调用签名接口的完整 JS 片段
前端的工作量比后端小,但同样有严格顺序:拿到签名结果后再注入wx.config。在页面加载时请求一次接口,不要等到用户点击分享时才发请求,避免签名还在路上用户就点了分享。
async function getWxSignature() { // 去掉 # 锚点,保证与后端签名用的 url 完全一致 const currentUrl = location.href.split('#')[0]; const res = await fetch( 'https://api.example.com/wechat-share/api.php?url=' + encodeURIComponent(currentUrl) ); const data = await res.json(); if (data.errcode !== 0) { throw new Error(data.errmsg); } return data.data; } getWxSignature().then(signature => { wx.config({ debug: false, appId: signature.appId, timestamp: signature.timestamp, nonceStr: signature.nonceStr, signature: signature.signature, jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData'] }); });encodeURIComponent只负责传输层编码,后端接收后原样还原。如果直接把 URL 拼到请求里不加编码,遇到&或多个查询参数会被截断。
4.2 分享链接带上标题与缩略图的 wx.config 配置
签名通过后,还需要在wx.ready回调里主动设置分享内容,配置项里的link同样要用去掉#的地址,与签名阶段保持一致:
wx.ready(() => { wx.updateAppMessageShareData({ title: '这里是自定义标题', desc: '分享给好友时显示的描述文字', link: location.href.split('#')[0], imgUrl: 'https://cdn.example.com/share-cover.jpg', success: () => {} }); wx.updateTimelineShareData({ title: '分享到朋友圈的标题', link: location.href.split('#')[0], imgUrl: 'https://cdn.example.com/share-cover.jpg', success: () => {} }); });imgUrl必须使用 HTTPS 地址,域名要和当前页面同一个已通过 JS 接口安全域名校验的域名,否则缩略图拉取不到。标题和描述如果来自接口异步数据,务必在拿到数据之后再调用这两个方法,不要在wx.ready一开始就填入空字符串。
4.3 SPA 路由跳转后签名失效的前后端配合
前后端分离项目中,单页应用切路由不会触发整页刷新,wx.config又只在初始化时注入了一次。如果页面标题、描述会随路由变化,需要重新请求签名接口并再次调用wx.config。
处理方式是监听路由变化,在进入新页面后重新走一遍签名流程。此时location.href可能没有变化,实际变化的是history里的路径,要取location.href.split('#')[0]作为签名的基准。后端不用感知前端框架细节,每次收到新url就重新生成签名,天然适配 vue-router 或 react-router。
4.4 invalid signature 常见原因对照表
| 现象 | 真正原因 | 处理方式 |
|---|---|---|
| 初次接入就报 invalid signature | JS 接口安全域名未配置或校验文件未放对位置 | 公众号后台配置域名,下载校验文件放到站点根目录 |
| 分享卡片正常,偶尔报错 | ticket 缓存过期边界处理不当 | 确认expire_at是否提前 200 秒刷新 |
| 带查询参数的页面报错 | 前端 sign 时漏了参数或顺序不对 | 统一使用location.href.split('#')[0]原样传递 |
| 拼接串检查无误仍报错 | 把nonceStr字段名写进了拼接参数 | 拼接字符串里固定用noncestr全小写 |
| 页面在 iframe 中打开报错 | 签名用了 iframe 内部 url,微信取的是顶部页面 | 改为顶层window.top.location.href传递 |
5. 上线前自检脚本与 timestamp 容错:把签名验证做到可观测
5.1 一条命令自检签名算法
把buildSignature抽成静态方法后,可以写一个不经 HTTP 请求的自检脚本,直接验证本地拼接逻辑与微信官方算法是否一致。
<?php require __DIR__ . '/inc/WechatShareSigner.php'; $url = $argv[1] ?? ''; if ($url === '') { echo "用法: php selftest.php 'https://example.com/page?id=1'\n"; exit(1); } $config = require __DIR__ . '/inc/config.php'; $signer = new WechatShareSigner($config); // 取一次签名,返回结果里的 signature $result = $signer->signature($url); // 用同样的原料再手工拼一次 $ticket = $signer->getJsApiTicket(); $localSignature = WechatShareSigner::buildSignature( $ticket, $result['nonceStr'], $result['timestamp'], $url ); echo '接口签名: ' . $result['signature'] . "\n"; echo '本地复算: ' . $localSignature . "\n"; echo $result['signature'] === $localSignature ? "自检通过\n" : "自检失败: 拼接串或 sha1 算法有问题\n";这个脚本主要验证两件事:curl扩展可用,以及当前 PHP 的sha1拼接结果与接口返回一致。脚本会触发一次真实的微信接口请求,首次运行能看到access_token和jsapi_ticket缓存文件被创建,正好检查目录权限是否正常。
5.2 x-timestamp 过期的两种容错写法
实际操作中常遇到前端请求头里带x-timestamp、后端校验时间窗口的场景。如果签名接口本身也做了类似的时间戳校验,要特别注意前后端时钟偏差。
第一种处理是放宽校验窗口。微信服务端和业务服务器时间允许最多 5 分钟偏差,后端判断abs($clientTimestamp - time()) > 300时才拒绝,避免用户手机时间不准导致签名在生成端就被卡住。第二种是缓存层加少量冗余,把expires_in减 200 秒而不是减 0,这样即使微信服务器时间略快,后端提供的 ticket 也不会在最后一秒失效。
5.3 日志字段:排错时最有用的一行
最后给签名接口加一行结构化日志,字段固定下来,线上出问题能直接定位。建议至少记录请求 url、appId、签名是否成功、ticket 来源是缓存还是新获取。
2025-05-01 12:00:11 | wx1234567890abcdef | https://example.com/page?id=88 | ticket_from_cache=1 | errcode=0ticket_from_cache字段特别有用:如果线上频繁出现某台机器签名失败,但其他机器正常,通常就是该机器缓存目录不可写,进程每次都在重新获取 ticket,导致微信接口限流。有了这行日志,一眼就能判断缓存命中率和异常来源。
本文还有配套的精品资源,点击获取