如何为 Chrome extension 构建支付与许可证系统?
结论
不要在 Chrome extension 或 Electron 客户端内直接处理收款,也不要把支付平台密钥、许可证生成逻辑或永久解锁凭证放进客户端。
比较稳妥的做法是:
- 通过第三方支付服务的托管结账页完成付款。
- 用自己的后端接收支付服务发送的 webhook。
- 后端根据 webhook 创建或更新用户的 entitlement(功能使用权)。
- 扩展或 Electron 应用在用户登录后,通过后端 API 查询授权状态。
- 退款、订阅取消、拒付和许可证吊销也通过 webhook 同步。
WordPress 许可证插件并非不能使用,但要充当后端,必须提供稳定的 HTTP API、服务端验证和吊销机制。如果必须模拟 WordPress theme/plugin 的激活流程,长期维护成本通常比搭建一个小型许可证服务更高。
推荐架构
典型流程如下:
Chrome extension / Electron app
│
│ 1. 请求创建结账会话
▼
Licensing backend
│
│ 2. 使用服务端密钥调用支付平台
▼
Hosted checkout page
│
│ 3. 用户完成支付
▼
Payment provider
│
│ 4. 发送签名 webhook
▼
Licensing backend
│
│ 5. 更新 entitlement
▼
Extension 查询授权并解锁功能
支付成功页面不能作为授权依据。用户可能伪造跳转地址,网络也可能在付款完成后中断。只有通过签名验证的 webhook,或者经后端主动向支付平台核实的交易,才能改变授权状态。
支付服务应支持托管结账、webhook、退款和订阅管理。可用产品、费率、税务代缴范围以及 Chrome Web Store 政策都可能变化,接入前需要查阅相关平台和商店的最新文档。
先确定许可证模型
开始实现前,先明确具体出售什么。
永久许可证
用户一次性付款,获得某个产品或主要版本的永久使用权:
{
"product": "extension-pro",
"status": "active",
"type": "lifetime"
}
如果以后准备对大版本再次收费,可以增加 majorVersion 或 plan 字段,不要只保存一个难以扩展的布尔值。
订阅许可证
授权取决于订阅状态和当前计费周期:
{
"product": "extension-pro",
"status": "active",
"type": "subscription",
"currentPeriodEnd": "2026-10-01T00:00:00Z"
}
后端还要处理取消、续费失败、退款和拒付。取消订阅不一定会让授权立即失效,有些业务允许用户继续使用到当前计费周期结束。
设备许可证
如果要限制设备数量,应由后端维护激活记录,例如:
license_id
device_id_hash
activated_at
last_seen_at
revoked_at
设备标识只能辅助限制滥用。浏览器配置可能被删除或复制,硬件指纹也有隐私、稳定性和误判问题。一般更适合使用“用户账号 + 合理的设备数量上限”,同时提供设备解绑功能。
后端数据设计
一个小型许可证服务至少要保存以下信息:
users
id
email
created_at
orders
id
provider
provider_customer_id
provider_order_id
status
product_id
user_id
entitlements
id
user_id
product_id
status
type
expires_at
updated_at
webhook_events
provider
event_id
processed_at
webhook_events 用来保证幂等性。支付平台可能重复发送同一个 webhook,系统不能因此重复创建订单或多次延长有效期。
支付平台的 customer_id、subscription_id 和 order_id 应作为外部标识保存,不要用邮箱地址充当唯一交易标识。用户可能更换邮箱,也可能使用与付款邮箱不同的产品账号。
创建托管结账会话
下面是简化后的 Node.js 示例。支付平台相关代码采用抽象接口,因为不同服务使用的参数、签名算法和事件名称并不相同。
import express from "express";
const app = express();
app.post("/api/checkout", requireUser, async (req, res) => {
const user = req.user;
const productId = "extension-pro";
const session = await paymentProvider.createCheckoutSession({
productId,
customerReference: user.id,
customerEmail: user.email,
successUrl: "https://example.com/payment/success",
cancelUrl: "https://example.com/payment/cancel",
metadata: {
userId: user.id,
productId
}
});
res.json({
checkoutUrl: session.url
});
});
paymentProvider 的 API 密钥只能放在后端。扩展端只获取一次性的结账 URL:
async function startCheckout() {
const response = await fetch("https://api.example.com/api/checkout", {
method: "POST",
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`Checkout request failed: ${response.status}`);
}
const { checkoutUrl } = await response.json();
await chrome.tabs.create({
url: checkoutUrl
});
}
使用 Manifest V3 时,需要在 host_permissions 中声明后端域名:
{
"manifest_version": 3,
"name": "Example Extension",
"version": "1.0.0",
"permissions": ["storage"],
"host_permissions": [
"https://api.example.com/*"
]
}
不要把支付页面嵌入扩展弹窗。托管结账页通常涉及重定向、身份验证、银行卡验证和第三方脚本,放在普通浏览器标签页中打开更加可靠。
使用 webhook 发放授权
后端需要保留 webhook 请求的原始 body,因为许多支付平台会用原始字节计算签名。不能先解析 JSON,再对重新序列化的内容验签。
app.post(
"/api/payment-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = paymentProvider.verifyWebhook({
rawBody: req.body,
signature: req.headers["payment-signature"],
secret: process.env.PAYMENT_WEBHOOK_SECRET
});
} catch {
return res.status(400).send("Invalid webhook signature");
}
const alreadyProcessed = await webhookEventExists(event.id);
if (alreadyProcessed) {
return res.sendStatus(200);
}
await database.transaction(async (tx) => {
if (event.type === "payment.completed") {
const userId = event.metadata.userId;
const productId = event.metadata.productId;
await tx.entitlements.upsert({
userId,
productId,
status: "active",
type: "lifetime",
expiresAt: null
});
}
if (
event.type === "payment.refunded" ||
event.type === "subscription.terminated"
) {
await tx.entitlements.update({
userId: event.metadata.userId,
productId: event.metadata.productId,
status: "revoked"
});
}
await tx.webhookEvents.insert({
provider: "configured-provider",
eventId: event.id,
processedAt: new Date()
});
});
res.sendStatus(200);
}
);
示例中的事件名称只是架构占位符,不能直接套用到具体支付平台。实际实现必须使用该平台定义的事件类型、签名请求头和验签 SDK。
还要检查 webhook 中的商品、金额、币种和交易状态,不能仅凭 metadata 中存在 userId 就发放授权。
扩展如何验证授权
最简单的方式是让用户登录,再查询自己的 entitlement:
async function fetchEntitlement() {
const token = await getAccessToken();
const response = await fetch(
"https://api.example.com/api/me/entitlements/extension-pro",
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
if (!response.ok) {
throw new Error(`Entitlement request failed: ${response.status}`);
}
return response.json();
}
后端只返回必要的信息:
{
"product": "extension-pro",
"status": "active",
"expiresAt": null,
"checkedAt": "2026-09-02T08:30:00Z"
}
扩展随后缓存查询结果:
async function refreshLicense() {
const entitlement = await fetchEntitlement();
await chrome.storage.local.set({
entitlement,
entitlementCachedAt: Date.now()
});
return entitlement.status === "active";
}
缓存可以改善离线体验,但不能充当永久授权记录。例如,可以让已成功验证的用户离线使用若干天,超过宽限期后再要求联网验证。宽限期应根据产品情况决定,没有适用于所有应用的固定时长。
是否需要许可证密钥
如果用户始终通过账号登录,通常不需要传统许可证密钥。账号与 entitlement 的关系已经足够清楚,用户也不用复制一长串字符。
如果业务确实需要许可证密钥,例如不要求用户注册账号、支持企业批量采购或线下分发,可以生成高熵随机值:
import crypto from "node:crypto";
function createLicenseKey() {
return crypto.randomBytes(24).toString("base64url");
}
数据库不应明文保存完整密钥,可以像处理密码一样只保存摘要:
function hashLicenseKey(licenseKey) {
return crypto
.createHash("sha256")
.update(licenseKey, "utf8")
.digest("hex");
}
许可证密钥本身应有足够高的随机性,因此可以使用 SHA-256 进行数据库查找并降低泄露风险。如果许可证由用户选择,随机性很低,则应使用专门的密码哈希方案,而不是普通 SHA-256。
激活请求可以写成:
POST /api/licenses/activate
Authorization: Bearer <user-access-token>
Content-Type: application/json
{
"licenseKey": "generated-license-key",
"deviceId": "installation-identifier"
}
后端检查许可证状态、产品类型和设备上限,再返回授权结果。客户端不能只检查密钥格式,因为攻击者可以直接删除这段检查代码。
身份认证的选择
Chrome extension 常用的认证方式有两种:
- 用户在网站完成登录,再通过一次性授权码把会话交给扩展。
- 使用 OAuth/OIDC,并采用适合公共客户端的 Authorization Code Flow with PKCE。
扩展和 Electron 应用都属于 public client,无法安全保存 OAuth client secret。即使对 secret 进行打包、压缩或混淆,仍然可以被提取。
一次性授权码应符合以下要求:
- 有效时间短;
- 只能使用一次;
- 与目标用户和客户端绑定;
- 兑换后立即失效;
- 不在 URL 中直接传递长期 access token。
Access token 也应设置较短的有效期。是否在扩展中保存 refresh token,要结合认证服务能力和风险模型决定。一旦保存,就需要考虑恶意扩展、浏览器同步、调试接口和本机恶意软件造成的泄露风险。
WordPress 插件是否适合
可以通过以下问题判断:
- 是否提供正式、稳定的 REST API?
- 能否在服务端创建、激活、停用和查询许可证?
- 能否处理退款、续费失败和订阅取消?
- 是否支持 webhook 或可靠的支付状态同步?
- 是否具备幂等处理、访问控制、速率限制和审计日志?
- API 是否支持自定义产品,而非强依赖 WordPress plugin/theme 更新机制?
- 插件升级后,扩展使用的接口是否有兼容性承诺?
如果大部分答案都是“是”,它可以作为许可证管理后台,Chrome extension 则是另一个 API client。
如果激活时必须伪造 WordPress 站点 URL、theme slug 或 plugin version,说明数据模型与实际业务不匹配。短期内可能可以运行,但退款处理、设备解绑和账号迁移会越来越难维护。
一种折中方案是在插件前增加自己的后端适配层:
Extension → 自有 API → WordPress licensing plugin
不要让扩展直接调用 WordPress 管理接口。适配层可以隐藏插件凭据、统一认证并限制请求频率。以后更换许可证系统时,也不必更新所有客户端。
Electron 应用的差异
Electron 可以通过系统浏览器打开结账页:
import { shell } from "electron";
await shell.openExternal(checkoutUrl);
付款后,可以通过 HTTPS 页面提醒用户返回应用,也可以注册自定义协议或应用链接实现回跳。回跳只能改善使用体验,不能证明付款成功。应用仍需向后端查询 entitlement。
Electron 同样无法安全保存永久密钥。用户可以访问应用程序文件和运行环境,也能读取打包资源、修改 JavaScript 或拦截本地函数。代码签名、完整性检查和混淆可以增加修改成本,但不能取代服务端授权。
如果核心功能完全离线运行,许可证校验最终都有可能被绕过。这种情况下,更现实的目标是减少普通盗用,而不是追求客户端绝对不可破解。
容易踩到的坑
把解锁状态只存到本地
下面的逻辑没有实际安全性:
await chrome.storage.local.set({ isPro: true });
用户既可以修改本地数据,也可以修改扩展代码。真正的 entitlement 必须保存在服务器中,本地状态只能作为缓存。
在扩展中保存支付密钥
Manifest、JavaScript bundle、source map 和网络请求都能被检查。任何发送到客户端的 secret 都应视为公开信息。
只处理付款成功
完整的系统还要考虑:
- 重复 webhook;
- webhook 延迟或乱序;
- 退款与部分退款;
- 拒付;
- 订阅取消和续费失败;
- 用户更换邮箱;
- 设备丢失与解绑;
- 支付成功但客户端没有收到回跳;
- 支付平台暂时不可用;
- 删除账号和隐私合规要求。
每次操作都在线验证
频繁验证会增加延迟,后端一旦出现短暂故障,也会直接影响付费用户。通常应该设置短期缓存和离线宽限期,并在扩展启动、缓存过期或执行重要操作前刷新授权。
依靠签名 JWT 实现永久离线授权
后端签名的许可证可以防止用户自行生成有效许可证,却无法阻止用户修改客户端并跳过验签代码。采用离线许可证时,应明确它只能增加破解难度,无法建立不可绕过的安全边界。
一个可落地的最小方案
独立开发者的第一版不必设计得太重:
- 选择支持 hosted checkout 和签名 webhook 的支付服务。
- 建立用户登录系统。
- 创建
orders、entitlements和webhook_events三组数据。 - 后端提供创建结账会话、查询 entitlement 和处理 webhook 的接口。
- Chrome extension 在普通标签页中打开结账页。
- 付款后由 webhook 发放权限,扩展通过轮询或手动刷新获取授权状态。
- 在本地缓存最近一次有效结果,并设置合理的离线宽限期。
- 在管理后台提供查询、吊销、恢复和设备解绑功能。
- 上线前测试重复通知、退款、取消订阅、断网和账号迁移。
这套设计不依赖 CWS Payments,也适用于 Electron。以后更换支付服务时,只需替换后端的支付适配层,扩展使用的 entitlement API 可以保持不变。
备注:内容仅供参考。
