
最近在评审一个前后端分离项目的登录方案时又被问到两个看似简单的问题项目已经全站上了HTTPS为什么还要用JWT反过来接口都换成JWT鉴权了HTTPS是不是可以省了这两个问题放到一起恰恰说明很多同学在搭接口安全体系时把“传输加密”和“身份认证”这两件事混成了一谈。HTTPS和JWT一个是管路的一个是管人的二者是互为补充的关系谁也没法替代谁。这篇文章我想把这两块内容彻底拆开来讲先聊清楚HTTPS的加密链路是怎么建立的再深入JWT的结构和验签流程然后把“落地时最容易踩的坑”逐一拉出来。最后我会结合自己在一个多端项目里的实际选择聊聊这套组合方案的收益与代价。不管你是刚接触安全刚需的前端开发、正在设计登录模块的后端还是负责整个网关层的基础设施同学这篇都适合你。1. 加密与认证不是一回事先从一次裸奔的登录请求说起1.1 抓包工具里看到的“明文世界”我之前在本地环境调一个老项目还没接HTTPS。打开抓包工具在登录接口上输入一组测试账号提交以后那个请求体的内容几乎是“裸奔”的状态POST /api/login HTTP/1.1 Host: shop.example.com Content-Type: application/json { username: tester, password: 123456 }用户名、密码、甚至带在Cookie里的会话ID全部以明文形式暴露在网络链路上。这不是什么罕见的场景只要你连的路由器、运营商的接入节点、或者某个代理设备上装了抓包工具就能看到同样的内容。更麻烦的不只是“被偷看”而是“被篡改”。我们常说的中间人攻击本质上就是攻击者在通信链路的某一环插入自己把客户端发给服务端的请求改掉再转发过去。比如你想转账请求到了中间人手里收款账号被悄悄替换了。所以单靠HTTP做业务尤其是登录、支付、个人信息这类核心接口就等于把家门钥匙挂在了门口。1.2 HTTPS管的是通道JWT管的是身份明确区分两件事HTTPS解决的是“数据在传输过程中是否安全”。它做的事情相当于给请求内容套了一个加密信封从发送方到接收方的整条通道都有加密保护中途的人看不到信的内容也改不了信的内容。JWT解决的是“服务端如何确认当前请求者的身份”。它相当于包裹上的防伪身份标签。服务端撕下标签、验一下签名就能确认这封信确实是“某个已登录用户”发来的而不是随便伪造的。我用一个快递场景来帮你记押运车负责HTTPS防伪标签负责JWT。押运车可以保证运输途中没人偷看但快递到了驿站驿站还是得看面单上的收件人信息和防伪标识才能确认这件货是谁买的。你不可能说“我这包裹是用押运车送的所以标签就不用看了”也不可能说“我贴了防伪标签所以用普通三轮车送就行”。两个环节各有各的防护目标缺一不可。1.3 这套组合到底适合什么样的项目如果我今天在做一个纯服务端渲染的MVC老项目页面都由后端模版渲染登录态完全依赖Session和Cookie那“Session Cookie HTTPS”其实已经够用。因为会话状态在服务端有全局视角吊销和续期都方便浏览器也会自动带Cookie。但如果项目是前后端分离、有独立移动端App、或者服务要提供给多个业务方调用那就需要JWT这类自包含的令牌机制。它的优点是无状态、跨端、容易水平扩展。注意我用的是“需要”不是“必须”因为传统Session在网关里做了集中存储以后也能支撑这类场景只是代价会越来越大。不少读者会有一个错觉我上了HTTPS登录就安全了。实际上HTTPS只保证“客户端到服务端”这段路安全服务端依然无法知道请求到底是哪个用户发出来的。反过来我做了JWT鉴权却不开HTTPS那令牌从客户端传到服务端的路上随时可能被中间人截走。拿到令牌的人就等于拿到了你的登录态。所以这两样东西天然是配套关系而不是替代关系。2. HTTPS握手一步步拆解会话密钥是怎么在公开信道上安全建立的2.1 为什么握手用非对称加密数据传输却用对称加密很多人第一次接触HTTPS都会问同一个问题既然公钥加密、私钥解密这套非对称加密这么好用为什么不直接用公钥加密所有数据还要搞一个“会话密钥”出来核心原因是性能。以目前主流CPU的运行效率来看对称加密算法比如AES处理数据的吞吐量要比非对称加密算法比如RSA、ECC高出好几个数量级。如果你把一张图片、一段视频、一个几十MB的JSON响应全部用非对称加密服务端的CPU很快就扛不住了。所以TLS的设计非常务实每次连接开始时通过非对称加密协商出一个“临时会话密钥”一旦这个密钥在两端的达成一致后续所有业务数据都用对称加密快速收发。你可以理解成两个人先在电话里用保险柜商讨好了一个临时密码然后接下来的所有快递都统一用这个密码锁箱效率和安全都有了。2.2 TLS 1.2 握手的关键环节还原为了让你能跟着操作排查问题我把TLS 1.2握手过程拆成几步按实际发生的顺序走一遍ClientHello客户端生成一个随机数并把自己支持的加密套件列表、TLS版本号发给服务端。ServerHello服务端从列表里选一个双方都支持的加密套件同时生成自己的随机数一并返回给客户端。Certificate服务端把自己的证书链服务端证书中间CA证书发给客户端。ServerKeyExchange在ECDHE这类密钥协商套件里服务端还会发送自己的临时公钥参数。客户端验证证书客户端拿到证书后会校验证书是否可信、域名是否匹配、是否过期如果校验通过则继续。客户端发送ClientKeyExchange客户端用自己的临时私钥和服务端的临时公钥计算出一个“预主密钥”并把它在服务端公钥的加密保护下发给服务端。双方各自计算会话密钥客户端和服务端分别用两个随机数预主密钥跑一个伪随机函数得到完全相同的会话密钥。Finished消息双方用刚协商出来的会话密钥加密一条“握手完成”的消息如果对方能正确解密并校验握手就正式完成。你会发现整个握手过程中真正的“秘密”只有预主密钥。它一旦被安全地交换出来后续的加密通信就全依赖它派生出的会话密钥了。如果抓包的时候想看TLS握手详情抓包工具里通常会显示“ClientHello”“ServerHello”“Certificate”“ClientKeyExchange”这些阶段按这个顺序去读很容易定位是在证书校验还是在密钥协商环节出的问题。2.3 证书链校验与浏览器报错的常见原因HTTPS的可信基础不是“加密”本身而是“证书是谁签的”。服务端证书由CA签发CA证书又由更上层的根CA签发形成一条信任链。浏览器里预装了根CA的证书只要能递归信任到某一个根CA就算这条链可信。实际操作里最常见的情况我用一个表格整理出来报错现象根因解决方向证书已过期服务端证书超过有效期续期并重新部署证书证书名称不匹配访问域名和证书中的CN/SAN不一致确认访问的是证书覆盖的域名不是私密连接使用了自签名证书内网环境安装根证书公网环境必须用受信任CA证书证书链不完整服务端只部署了域名证书缺少中间CA证书把证书文件和中间证书合并为完整chain移动端App请求失败客户端环境不信任该证书颁发者统一走系统证书校验不要盲目关闭校验收场其中最容易让人困惑的是“浏览器能打开但App请求报错”。浏览器内置的证书库很全一般不会出问题但App客户端如果用了WebView或原生网络库证书校验依赖的是系统证书库某些环境中机构签发的证书没有安装到设备上就会直接校验失败。排查这类问题时我一般会先用在线证书检测工具查一下证书链是否完整再用openssl手动拉一遍证书链往往比在业务代码里反复打日志快得多。2.4 握手是开销但不是不可控TLS 1.3和会话恢复每次HTTPS请求都要先做一次TCP握手再做一次TLS握手相比HTTP确实多了几个RTT的开销。第一次访问时用户的体感会特别明显页面白屏时间更长。好在这部分开销是可控的。TLS 1.3把握手压缩到了1个RTT而且很多实现支持0-RTT的会话恢复也就是客户端本地缓存上一次的会话票据下次连接时直接就能发加密数据省掉一整个往返。部署层面的常见优化还包括在负载均衡上开启TLS会话复用、开启OCSP Stapling把证书状态查询内嵌到握手过程、用HTTP/2来合并并发请求连接。我在实际运维中见过这样的坑某个网关设备为了性能把TLS会话缓存时间设得特别短结果客户端每次请求都要重新握手用户的平均响应时间硬生生多了几十毫秒。排查到最后不是代码问题而是会话缓存策略问题。这类基础设施层面的参数和业务代码一样值得列进监控和压测范围。3. JWT三段式结构解读令牌为什么可以自己证明自己3.1 一长串字符拆成三段JWT全称是JSON Web Token结构上就是三个用点号分隔的字符串。随便拿一个实际令牌来做拆解eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImFkbWluIiwiaWF0IjoxNzE5OTk5OTk5LCJleHAiOjE3MjAwMDA4OTl9.9VqkZlN8x00gur6p2LrDj2fDF2xL28BcGah6Vt2j4Uo从左到右分别是Header令牌头描述签名算法和令牌类型。Payload载荷存放身份声明和自定义数据。Signature签名由前两段加上密钥计算得到。注意它们不是普通的Base64而是Base64URL编码把标准Base64里的改成-/改成_并且去掉末尾的。因为JWT经常会放在URL参数、HTTP头或者Cookie里带和/的字符容易产生歧义URL安全的编码方式能避免这类问题。3.2 Header和Payload里能放什么不该放什么Header主要就两个字段{ alg: HS256, typ: JWT }Payload是可选的“声明集合”。JWT定义了一组注册声明最常用的有sub令牌主体比如用户ID、iss签发者、aud受众、exp过期时间、iat签发时间、jti令牌唯一标识。除了这些你也可以加自定义字段比如角色、用户类型等。这里必须提醒一句JWT默认只做签名不做加密。也就是说任何拿到令牌的人都可以用Base64解码中间那段Payload直接看到你放进去的所有字段。所以密码、手机号、身份证号、内部权限点等敏感信息一个都不要放进Payload。有人会问那我能用JWE吗JWE是JWT的加密版本确实可以加密内容但它在实际项目里遇到得不多因为引入之后调试成本会高不少。对绝大多数场景正确做法是Payload只放非敏感标识比如用户ID、角色真正的敏感数据放在服务端需要的时候再查库。3.3 Signature签名与验签的全过程签名的作用是保证令牌内容没被篡改。以最常用的HS256为例签名生成过程是对Header和Payload分别做Base64URL编码。用英文点号拼成“header.payload”这个字符串。用服务端保存的密钥对这个字符串计算HMAC-SHA256。再对得到的二进制哈希做Base64URL编码得到Signature。服务端验签时拿到令牌前两段用同一个密钥重新计算签名和令牌第三段比对。不一样说明内容被改过一样则说明这段令牌确实是服务端签发的。这里有个安全细节不可忽视HS256是“对称签名”签名和验签用的是同一个密钥。也就是说谁有密钥谁就能伪造令牌。RS256是“非对称签名”签发方用私钥签名校验方用公钥验签密钥天然分离更适合多个服务共享验签的微服务架构。在实际项目中我很建议在校验环节增加一个“算法白名单”判断只允许RS256或ES256这类指定的算法进入验签流程。如果不限制攻击者可能把Header里的算法改成HS256然后诱导服务端用公钥作为HMAC密钥来验签从而伪造任意身份。这是业界曝光过多次的真实攻击手法限制算法是成本最低的防御手段。3.4 JWT与Session对比无状态的好处和代价做个直白的对比维度Session CookieJWT服务端存储需要存Session数据不需要状态在令牌内水平扩展需要共享Session存储Redis等任意节点都可验签主动吊销直接删Session即可无法直接吊销只能等过期或拉黑令牌体积很小通常一个ID较大一般200字节以上客户端适配浏览器自动带Cookie需显式放在Header或本地存储安全边界依赖Cookie属性管理依赖签名和过期策略“无状态”最吸引人的点是扩容时不需要迁移会话。我有一个项目在切换JWT之前每次新增一台应用节点都要确保新节点能连上同一套Session存储否则用户会被随机踢下线。换了JWT之后新节点只要配好验签公钥立刻就能接流量。但代价也同样明显token一旦签发出去服务端没有主动注销的能力。某个员工离职了你无法让他的token立刻失效。所以现实项目里的妥协方案是access token寿命设得很短配合refresh token续期再用黑名单机制兜底。这部分我在第5章详细展开。4. 把JWT接入项目的完整思路从生成到校验再到密钥管理4.1 登录成功后怎么签发令牌以最常见的账号密码登录为例当用户把用户名和密码提交到服务端服务端校验通过之后要做的是生成两个令牌accessToken访问令牌寿命短通常10到30分钟。refreshToken刷新令牌寿命长通常7到30天只能用来换新的accessToken。直接给一段示意代码用的是Node.js生态里常见的JWT库写法其他语言的API结构也类似const jwt require(jsonwebtoken); // 登录成功后的逻辑 const accessToken jwt.sign( { sub: user.id, role: user.role, }, process.env.JWT_SECRET, { algorithm: HS256, expiresIn: 15m, issuer: my-api, audience: my-app, } ); const refreshToken jwt.sign( { sub: user.id, type: refresh, }, process.env.JWT_REFRESH_SECRET, { algorithm: HS256, expiresIn: 7d, issuer: my-api, audience: my-app, } );注意到几个细节签发者的名称建议用域名或固定项目名受众也是一个固定值这样不同环境下的令牌不会互相乱用。sub只放用户ID不要放整个用户对象。至于用户角色这类经常变化的字段我也不建议放进去。因为令牌一旦签发里面的角色就是“冻结”的状态改角色要等token过期再生效容易造成权限延迟和误解。如果角色信息经常变就让校验方每次按sub查库或查缓存。4.2 校验环节的完整清单很多项目在校验JWT时只做了一件事验签。实际上一个安全的校验流程至少包含这几步确认算法在白名单内。用正确的密钥验签。校验过期时间exp拒绝已过期的令牌。校验签发者iss、受众aud防止别人签发的令牌被混用。提取身份标识sub配合权限系统完成授权。示意代码如下const jwt require(jsonwebtoken); function verifyAccessToken(token) { const payload jwt.verify(token, process.env.JWT_SECRET, { algorithms: [HS256], issuer: my-api, audience: my-app, }); return payload; }这里还有一个容易出错的地方很多框架把令牌放在Authorization头里格式是Bearer token。如果你直接拿整个字符串去验签一定会失败。严格的做法是先判断前缀是不是Bearer再把后面的部分提取出来。另外不同服务之间如果共享了同一个密钥验签时一定要注意区分环境和用途不要让用户请求的令牌也能认作服务间调用的令牌。4.3 密钥怎么管环境变量、轮换与应急密钥就是JWT体系里最核心的资产。如果签名密钥泄露攻击者可以自己签一个有效期为100年的令牌直接冒充任意用户。所以密钥管理的几条底线这里必须写清楚不要硬编码在代码仓库里。用环境变量或密钥管理服务下发。开发、测试、生产环境必须使用不同密钥。给密钥设置轮换周期建议至少每季度轮换一次签名密钥。轮换时采用“双密钥并行”策略新密钥用于签发旧密钥保留一段时间的验签能力确保存量令牌平滑过渡。如果发生密钥泄露我的应急处理顺序是第一步立刻在网关层和校验层拒绝所有旧令牌第二步重新生成密钥并部署到所有节点第三步通知前端的活跃用户重新登录第四步翻查访问日志看看泄露前后有没有异常调用。这里要特别提一下很多人以为密钥是“永远不变”的于是一次配完就再也不管了。我见过的一次真实事故就是因为客户端包里的密钥被人反编译提取出来攻击者签了一批管理员令牌直到运维发现日志里有大量异常请求才反应过来。密钥轮换不只是安全审计的要求它本身就是降低损失的关键手段。5. 只有理解了边界方案才算安全JWT存储、刷新与攻击面分析5.1 令牌放在哪localStorage还是HttpOnly Cookie这是前后端分离项目里讨论最多的一个问题没有绝对的正确答案只有基于攻击面的取舍。localStorage的优点是前端读取方便调API时取出来放到Header里就行。缺点是脚本一旦有XSS漏洞攻击者通过注入的脚本可以直接window.localStorage.getItem(token)把令牌拿走而且这个过程对用户完全无感。HttpOnly Cookie的优点是浏览器不允许脚本读取XSS攻击者拿不到令牌内容。缺点是它会让浏览器在每次请求时自动携带容易触发CSRF风险。当然CSRF可以用SameSite属性、自定义Header校验等方式压制但多了一层配置工作。我给一个比较务实的组合建议场景accessTokenrefreshToken纯前端SPA无强CSP保护放内存/请求头放HttpOnly Cookie移动端App系统安全存储系统安全存储高安全内部系统放内存放HttpOnly Cookie 同源校验如果采用“access token放内存”的方案意味着页面刷新之后内存里的token就丢了用户需要重新走一次刷新流程。这恰恰是合理的设计通过刷新令牌再换一个access token用户无感续期同时把真正长期有效的refresh token保护在Cookie里。这个模式是目前我见过的在Web端安全性和体验之间比较平衡的做法。5.2 双令牌与刷新链用时间来压缩暴露窗口接入双令牌机制时核心思路是把“访问权限”和“续期能力”分开。就算access token被偷走攻击者能使用的窗口只有几分钟等它过期攻击者手里只剩一个没用的字符串。而refresh token即使被偷走服务端也要在刷新接口上做额外校验比如校验sub是否存在、设备指纹是否一致、刷新令牌是否已经轮换过等增加攻击成本。刷新令牌还有一个常见优化每次刷新时服务端不仅返回新的accessToken还会生成一个新的refreshToken同时把旧的refreshToken标记为已用。这样就算攻击者偷到了refreshToken在用户下一次正常刷新之后旧refreshToken就会失效攻击者无法持续续期。这个模式在实现时要注意新老refreshToken要有一个短暂的共存期避免极端情况下用户刚刷新完毕又因为并发请求被迫重新登录。5.3 常见攻击场景和对应防护围绕JWT常见的攻击场景和我建议的防护手段总结如下攻击方式攻击原理缓解手段重放攻击攻击者截获合法令牌重复发送请求短token有效期绑定设备/用户指纹必要时用jti做一次性标识XSS窃取令牌脚本注入后读取本地存储输入过滤CSPHttpOnly Cookie严格路由CSRF第三方网站利用Cookie自动携带触发请求SameSite自定义Header校验CSRF token算法混淆修改JWT Header的alg字段降级为HS256服务端强制算法白名单令牌落入日志网关/CDN/客户端日志记录Authorization头日志脱敏不下发令牌明文我在实际项目中遇到最多的是最后一类后端服务把请求头整包打进了日志包括Authorization字段。排查线上问题的时候日志平台里全是已经登录用户的token明文。这类问题不会立刻造成事故但一旦日志平台被拖库整个系统的登录态全没了。所以我的建议是日志脱敏规则里第一优先就是Authorization、Cookie、Password这类敏感字段。5.4 无状态的双刃剑与“准无状态”实践JWT看起来让服务端变成了“无状态”但真实系统往往做不到彻底无状态。比如一个用户被管理员封禁他的token在过期之前依然有效某台设备丢失上面的refresh token依然能换新的access token。这让运维同学很头疼。解决思路是做一个轻量的“失效层”。用一个内存型数据库保存三类信息用户级别的token版本号用户改密码、封禁、注销时递增版本号验签时拿jti或sub对应的版本号校验。短时黑名单临时把某个泄露的jti拉黑直到它自然过期。刷新令牌的“已用”状态防止refresh token被重放。这样一来JWT大部分时间还是无状态的真正紧急的吊销场景留一条后路。我一般把这种方式叫“准无状态”省掉了普通会话存储的全部开销但保留了安全熔断的能力。6. HTTPS与JWT配套使用的真实收益传输安全与身份安全的合力6.1 两条防线是怎么互相补位的我们把一次正常登录后的API调用完整走一遍用户在前端页面上发起请求HTTPS在传输层把请求内容加密中间人即使抓到了数据包看到的也只是密文请求到达服务端后网关先解密应用层再拿到Authorization头里的JWT用公钥验签、校验过期时间、确认sub对应的用户权限最后返回业务数据。在这个链路上如果只有HTTPS没有JWT那么任意一个能构造请求的人都可以声明自己是某个用户服务端完全没有验证手段。如果只有JWT没有HTTPS令牌在每次请求时都要裸奔在网络上被截获一次就等于登录态丢失。两者各管一道防线组合起来才能形成相对完整的安全闭环。6.2 性能、运维和开发体验的综合权衡HTTPS不是零成本第一次握手多耗费的RTT在弱网环境中很明显JWT也不是零成本每次验签需要做签名运算且令牌体积比Session ID大不少。一个含三个左右声明的JWT通常在200到280字节之间放在每次请求的Header里在网络带宽上确实比Session ID要多一点但在现代带宽条件下这点差距基本无感。更值得关注的是服务端存储的省心。传统Session方案下每个在线用户都占用一份服务端内存或数据库连接到了高并发场景你得专门搭一套Redis集群来存Session还要处理Session过期、淘汰策略、序列化失败等一系列问题。JWT方案把这份存储压力转移到了客户端服务端只需要维护一个很小的黑名单或版本号存储。从整体运维复杂度来看通常是有帮助的。还有一个我常被问到的问题是Service Mesh或API网关层要不要验签我的建议是网关负责做证书终结、防盗链、速率限制这类传输层控制JWT验证放在应用入口的中台服务里做因为只有业务应用才清楚iss、aud和用户状态的具体含义。把验签放到网关遇到多环境、多版本灰度时反而菜鸟难搞。6.3 我给团队的实践建议与个人体会如果让我根据项目形态给建议我会这么说纯Web内部后台、用户量不大Session Cookie HTTPS就够了开发效率最高。多端业务、需要水平扩展或开放API给第三方HTTPS JWT双令牌是最成熟的组合。对安全性要求苛刻的系统支付、金融类在前者基础上JWT只做短期身份凭证核心权限数据都要实时查库并且强制全链路日志留痕。我自己在一个多端项目里落地这套方案后的体会是最大的收益不是某个单独的安全指标提升了而是“安全逻辑终于有了清晰的边界”。传输层的责任由HTTPS和网关负责认证层的责任由JWT和刷新策略负责我不用再担心一个请求到底是该改加密还是改校验。出了问题先看TLS握手是否正常再看token验签是否有异常日志分层次排查就能快速定位。最后再分享一个小技巧给每一个JWT都加上jti字段它是令牌的唯一ID。平时它也许没什么用但一旦需要针对某个令牌做短时拉黑、追踪泄露链路、审计单用户登录历史时你就能拿着jti做精确管理。这比等token过期再处理要灵活得多。安全方案永远是在“体验”和“风险”之间做取舍而提前给未来留好退路是每个工程师都应该养成的习惯。