在微信公众号网页授权中,state参数的本意是让开发者把授权前后的请求对应起来,同时阻止跨站请求伪造。微信开放平台会在用户确认授权后,把code和state原样回跳到redirect_uri。问题的关键在于,这个state完全由开发者生成,微信并不校验它的内容,任何能够在授权链接里嵌入自己state的人,都可能让一次正常的授权变成账号绑定攻击。因此,判断state是否被篡改,不能只看回调里的值是否与发起时一致,还要看它是否由你的服务端签发、是否过期、是否已经被使用过。

比较常见的错误做法是直接使用openid、手机号或固定字符串作为state,这类值要么可以被猜出,要么不能区分不同请求。攻击者可以提前构造一个带有自己state的授权链接并诱导用户点击,用户完成授权后,回调会携带攻击者预设的state返回,业务系统如果只检查参数是否存在,就会把用户的微信身份绑定到攻击者控制的会话上。下面的方案围绕签名、时效和一次性三个维度展开。
一、state被绕过到底会带来什么后果
微信公众号网页授权采用的是OAuth2.0授权码模式。用户点击授权链接后,微信会引导用户确认,随后携带code和state跳回开发者设置的回调地址。code用来换取openid和access_token,state则用来关联授权前后的状态。标准协议中state就是为防CSRF设计的,如果缺失或者校验不严,登录状态就可能被攻击者劫持。
典型的攻击流程是这样的:攻击者先在业务系统发起一次微信授权,拿到系统返回的授权链接,但在微信回调前中断流程。然后他把这个链接发给受害者,链接里的state是攻击者自己会话对应的值。受害者点击并完成授权后,微信把受害者的code和攻击者的state一起回跳到业务回调地址。如果业务只按state查询原会话,就会把受害者的微信身份绑定到攻击者预先准备的会话上。攻击者随后用自己的会话登录,就能看到受害者的数据,或者反过来把攻击者身份绑定到受害者账号。
所以单纯比较state是否等于发起时的值并不能防篡改。因为攻击者完全可以先走一遍正常流程,获得一个合法state。真正的防线是让state不可由外部生成,并且只能使用一次。签名和时效控制正是为此服务的。同时还要注意,微信虽然会校验回调域名,但攻击者仍可在同域名下替换state,因此不能把微信的域名校验当作唯一安全边界。
二、从随机串到签名:state的防篡改设计
防篡改state的设计可以拆成三部分:随机串、时间戳和签名。随机串使用加密安全随机数生成,保证不可预测;时间戳用来限制最大有效期;签名使用HMAC-SHA256,服务端持有密钥,攻击者无法伪造。生成时先拼接随机串和时间戳,再对拼接结果做签名,最后把三段内容组合成state。编码时建议使用Base64 URL安全编码,避免出现+、/和=等字符,方便在URL里传递。
下面是一个Java工具类,封装了生成和校验逻辑。校验时会先检查长度和结构,再重新计算签名,并比较时间戳是否在允许范围内。签名比较使用常量时间比较,可以避免时序攻击。
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
public class WechatStateSigner {
private static final SecureRandom RANDOM = new SecureRandom();
private static final long EXPIRE_SECONDS = 300;
private final byte[] secret;
public WechatStateSigner(String secret) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
public String generate() throws Exception {
byte[] randomBytes = new byte[16];
RANDOM.nextBytes(randomBytes);
String randomPart = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
long now = Instant.now().getEpochSecond();
String payload = randomPart + "." + now;
String sign = hmacSha256(payload);
return payload + "." + sign;
}
public boolean verify(String state) throws Exception {
if (state == null || state.length() > 256) {
return false;
}
String[] parts = state.split("\\.");
if (parts.length != 3) {
return false;
}
String payload = parts[0] + "." + parts[1];
String expectedSign = hmacSha256(payload);
if (!constantTimeEquals(expectedSign, parts[2])) {
return false;
}
long timestamp = Long.parseLong(parts[1]);
long now = Instant.now().getEpochSecond();
return Math.abs(now - timestamp) <= EXPIRE_SECONDS;
}
private String hmacSha256(String data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
private boolean constantTimeEquals(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}
这套设计的优势很明显:即使攻击者截获了一个合法state,也无法修改其中任意一部分而不破坏签名;即使签名算法被猜到,没有服务端密钥也无法生成新值。随机部分使用16字节安全随机数,时间戳精确到秒,300秒的窗口足够完成公众号授权,又不会给重放留下太长空间。实际部署时密钥应从配置中心读取,不要在代码仓库里硬编码。
三、服务端校验:微信回调里的关键一步
完整的授权流程中,服务端先生成state,并将它的摘要写入Redis作为一次性凭证。用户点击授权链接后跳到微信,微信回调返回code和state。回调接口必须先验签state,再拿code换取access_token。如果顺序颠倒,攻击者可能消耗有效code,导致正常用户无法完成登录。code本身有5分钟有效期且只能使用一次,因此对回调参数的任何校验都应该放在换取凭证之前。
下面以Spring Boot控制器为例,展示生成授权链接和回调校验的完整过程。授权时会生成state并写入Redis,使用setIfAbsent保证只写入一次。回调时先验签,再尝试占用state,占用失败说明已经被使用过。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.security.MessageDigest;
@RestController
public class WechatOauthController {
@Autowired
private WechatStateSigner stateSigner;
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/wechat/authorize")
public void authorize(HttpServletResponse response) throws Exception {
String state = stateSigner.generate();
String redisKey = "wechat:state:" + sha256(state);
redisTemplate.opsForValue().setIfAbsent(redisKey, "1", Duration.ofSeconds(300));
String appId = "your_app_id";
String redirectUri = "https://ipipp.com/wechat/callback";
String scope = "snsapi_userinfo";
String encodedRedirect = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8.name());
String url = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=" + appId
+ "&redirect_uri=" + encodedRedirect
+ "&response_type=code"
+ "&scope=" + scope
+ "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8.name())
+ "#wechat_redirect";
response.sendRedirect(url);
}
@GetMapping("/wechat/callback")
public String callback(@RequestParam("code") String code,
@RequestParam("state") String state) throws Exception {
if (!stateSigner.verify(state)) {
return "state校验失败";
}
String redisKey = "wechat:state:" + sha256(state);
Boolean first = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", Duration.ofSeconds(300));
if (!Boolean.TRUE.equals(first)) {
return "state已使用";
}
// 使用code换取access_token和openid
// ...
return "授权成功";
}
private String sha256(String input) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hash) {
String h = Integer.toHexString(0xff & b);
if (h.length() == 1) {
hex.append('0');
}
hex.append(h);
}
return hex.toString();
}
}
回调逻辑中还隐含了一个要求:state与当前请求的redirect_uri应该是绑定的。如果授权链接里的redirect_uri被改成了同域名下的其他接口,仅仅校验state并不能完全堵住风险。更严格的做法是把redirect_uri也纳入签名范围,回调时用请求中的redirect_uri重新计算签名,或者在生成state时把redirect_uri的哈希一并存储。微信公众平台已经要求配置回调域名,业务侧也应核对完整回调地址,避免授权码被导流到非预期入口。
四、上线前必须处理的防重放与边界问题
签名和过期时间可以防止篡改,但同一个合法state在有效期内仍可能被重复提交。如果攻击者截获了带state的回调请求,或者诱导用户重复点击同一个授权链接,就可能造成重放。因此state必须一次性使用。代码中使用Redis的setIfAbsent来做原子占用,第一次校验通过后会立即写入标记,后续请求即使签名正确也会被拒绝。注意不要分成先查询再写入两个步骤,并发场景下会出现多个请求同时通过的窗口。
另一个容易忽视的问题是state长度和URL编码。微信官方建议state不超过128字节,如果随机串过长或签名算法输出过长,可能触发参数截断。Base64 URL安全编码本身可以避免+、/等字符,但整个state拼进授权链接前仍应做URL编码。callback接收参数后,Web容器通常会自动解码,验签前不要重复解码。除此之外,回调域名必须与公众平台配置完全一致,生产环境强制使用https,避免授权码在传输层被窃听。
上线前建议用单元测试覆盖state工具类,重点验证篡改随机串、时间戳和签名后校验失败,过期state被拒绝,以及同一state重复验签通过但无法重复占用。也可以配合微信开发者工具模拟授权回调,确认链接生成、微信跳转、回调验签和code换取的完整链路没有断点。只有把签名、时效、一次性和域名校验组合起来,state才能真正成为公众号网页授权流程里的防篡改屏障。