你有没有想过一个问题:你在网站上登录一次,随后打开订单页、刷新个人中心、再开一个新标签页,网站为什么每次都知道“这是你”?
反直觉的地方在于,HTTP 请求本身没有连续记忆。服务器处理完一次请求,并不会因为下一次请求来自同一台电脑,就自动知道它们属于同一个人。网络地址会变化,一台设备也可能有多个用户;反过来,同一个用户还可能在手机和电脑上同时使用服务。单靠连接和地址,身份根本对不上。
真正把这些请求串起来的,是一份可以被验证的凭证。最常见的做法是:登录成功后,服务器生成一个不可预测的会话标识,把它交给浏览器保存在 Cookie 中;浏览器以后访问匹配的网站时会自动带上它。服务器每次仍然重新检查,只是这次检查的不是密码,而是“这张会话凭证是否有效、对应谁、现在还能做什么”。
所以,“保持登录”并没有让 HTTP 变成有状态协议。它只是让每个独立请求都带上了能恢复身份上下文的线索。后端认证要解决的,就是这条线索从创建、携带、校验、轮换到失效的完整生命周期。

认证回答“你是谁”,授权回答“你现在能做什么”。这两个问题经常挨着出现,却不能合并成一次判断。
假设小林已经登录,他打开自己的订单详情时,认证层先从会话中确认当前用户是小林;授权层再判断这张订单是否属于小林,或者他是否有客服权限。只做认证、不做对象级授权,结果可能是把地址栏里的订单编号从 1001 改成 1002,就看到了别人的订单。
一次完整的受保护请求通常经过这样的判断:
认证成功也不代表权限永远不变。用户被移出项目、管理员降级账号、组织停用成员后,旧会话里缓存的角色可能已经过时。越敏感的权限,越不能只相信登录那一刻的快照。
前端隐藏按钮不是授权。浏览器里的界面只负责改善体验,真正的权限判断必须发生在后端,并且要针对“动作 + 资源 + 当前主体”逐次检查。
没有提供有效身份凭证时,服务通常要求客户端先完成认证;身份已经确认但无权访问时,服务拒绝当前操作。在面向浏览器的页面里,后端也可能把未登录请求重定向到登录页,但 API 不应把“没登录”和“没权限”都模糊成业务成功响应。
对外错误信息要克制。例如登录失败时统一提示“账号或凭证不正确”,避免告诉攻击者“这个邮箱存在,只是密码错了”。但统一提示不等于把内部原因抹掉:审计日志仍应区分账号不存在、密码错误、账号停用、MFA 失败和风险策略拦截,只是不能把这些细节直接暴露给请求者。

登录表单把密码交给服务器时,很多初学者会自然地问:“数据库里要用什么密钥把密码加密起来?”这个问题的方向其实偏了。服务只需要判断用户下次输入的密码是否相同,并不需要还原原文,因此密码应做专用的单向哈希,而不是可解密存储。
注册时,后端为这个密码生成独立的随机盐,使用专门的慢速、可调成本的密码哈希算法计算结果,再保存算法标识、参数、盐和哈希值。登录时读取这些参数,用用户提交的密码重新计算并做安全比较。常见选择包括 Argon2id、scrypt;已有系统也可能继续使用参数合适的 bcrypt 或 PBKDF2,并在用户成功登录时逐步升级旧哈希。
盐不需要保密,它的作用是让相同密码得到不同结果,阻止攻击者用一张预先计算好的表批量匹配所有账号。可选的 pepper 则是另一层共享秘密,应与密码数据库分开保存在密钥管理系统中。pepper 一旦泄露或需要轮换,迁移成本会比盐高得多,所以它是纵深防御,不是替代强哈希的捷径。
慢速哈希的价值,是让合法登录多花一点可接受的计算成本,却让攻击者拿到数据库后进行数十亿次离线猜测变得昂贵。参数不能从博客里抄一个数后永远不变,而要在目标硬件上压测,设定服务能够承受的延迟和内存成本,并预留抗拒绝服务的容量。
注册:
salt = 安全随机值()
digest = 密码哈希(用户密码, salt, 当前参数)
保存(算法版本, 当前参数, salt, digest)
登录:
读取(算法版本, 参数, salt, digest)
candidate = 密码哈希(用户输入, salt, 参数)
if 安全比较(candidate, digest) and 参数已落后:
用新参数重新计算并替换这里没有“解密密码”这一步。忘记密码流程也不应把旧密码发回用户,而是发放短时、单次、可撤销的重置凭证,让用户设置新密码。

强迫用户每 30 天改一次密码、必须混合大小写和特殊符号,看起来很严格,实际常把用户推向可预测的变体。更有效的策略是允许足够长的密码和密码短语,支持密码管理器与粘贴,拒绝常见密码、已泄露密码和与账号高度相关的密码;没有泄露迹象时,不做机械的周期轮换。
在线攻击和离线攻击是两回事。慢速哈希主要拖慢数据库泄露后的离线猜测;对登录接口的暴力尝试、撞库和密码喷洒,还需要账号、来源、设备和全局多个维度的渐进限速。限速不能简单变成“输错三次永久锁号”,否则攻击者可以批量把正常用户锁在门外。
密码找回、邮箱换绑、MFA 重置和客服申诉也属于认证面。主登录做得再严,如果客服凭姓名和生日就能关闭 MFA,攻击者只会绕到更弱的入口。认证系统的安全强度,最终由最弱的恢复路径决定。
HTTP 自带挑战—响应式认证框架。服务器可以返回认证挑战,客户端再在 Authorization 请求头里提交凭证。Basic 和 Digest 都建立在这套框架上,但它们不是现代网站登录页的两个“安全等级选项”。
Basic 把用户名和密码拼接后做 Base64 编码。Base64 只是文本编码,任何拿到内容的人都能还原原文。因此 Basic 必须运行在正确验证证书的 HTTPS 连接上;即使有 HTTPS,客户端仍会在后续请求中反复发送可直接使用的长期密码,泄露后的影响通常比短期会话标识更大。
Basic 的优势是协议简单、工具支持广,适合受控网络中的简单工具、临时运维入口,或由额外安全边界保护的机器调用。它缺少网站常见的登录体验、精细会话控制和可靠的浏览器注销语义,也不适合承载 MFA、风险挑战和联合登录。把它直接暴露为面向公众的主认证方案,代价往往大于便利。
Digest 由服务器发出包含 realm 和 nonce 的挑战,客户端把用户名、密码派生值、请求方法、目标地址等组合成响应摘要。这样网络上不直接出现密码原文,现代定义也不只限于早期的 MD5,还包括更强的摘要算法以及用于减少重放的计数和随机量。
但“不直接传密码”不等于现代安全登录。弱密码仍可能被离线猜测;服务端为了验证响应,往往要保存可用于冒充客户端的等价秘密;客户端与中间设备支持不一致;它也没有天然提供网站需要的会话撤销、MFA、账号恢复和风控流程。TLS 还能同时保护请求内容与响应内容,而 Digest 只处理认证计算的一部分。
选择认证方案时,不要只问“哪一种算法更强”,还要问长期秘密出现在哪里、凭证能活多久、能否撤销、客户端是否真的支持,以及失窃后攻击者能做什么。
回到开头的刷新页面。用户提交登录信息后,服务器验证密码和附加因素,生成高熵、不可预测的会话标识,并在服务器端保存这个会话对应的用户、创建时间、认证强度、空闲时间和必要的风险状态。响应通过 Set-Cookie 把标识交给浏览器:
Set-Cookie: __Host-session=<不可预测的随机值>; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store后续请求只携带这个不透明标识,不再反复携带密码。服务器查到会话后恢复身份上下文,再执行授权。会话标识本身不应包含用户 ID、角色或时间戳等可预测信息,也不要放进 URL;URL 会进入浏览历史、日志、分析系统和引用来源,泄露路径太多。
Secure 让浏览器只在安全连接中发送 Cookie,但它不能补救错误的证书验证或应用层漏洞。HttpOnly 阻止页面脚本直接读取 Cookie,能降低一部分 XSS 窃取风险;如果页面已经被脚本控制,攻击者仍可能借用户浏览器发起操作。SameSite 限制跨站请求是否携带 Cookie。Strict 更严格,但用户从外部链接进入站点时可能看起来像“掉线”;Lax 常用于在可用性与 CSRF 缓解之间折中;确需跨站携带时使用 None,同时必须配合 Secure。Path 和 Domain 决定 Cookie 的发送范围,不应被当作可靠的同站应用隔离边界。会话 Cookie 通常不设置 Domain,并尽量使用 __Host- 前缀、Path=/ 和 Secure,减少子域覆盖或注入的空间。这些属性是不同的防线,不是勾选三个选项就“完成安全”。例如 HttpOnly 不能阻止 CSRF,SameSite 也不能修复同站脚本漏洞。要先说清攻击路径,再组合防护。
匿名访问阶段即使已经有一个临时会话,登录成功后也必须更换会话标识;权限提升、密码修改或风险事件后同样应轮换。旧标识要立即失效,避免攻击者预先把某个会话固定给受害者,再等受害者登录。
会话还应同时考虑空闲超时和绝对超时。空闲超时限制长时间不操作的会话,绝对超时保证持续活跃的会话也不能无限延长。具体时长取决于资产价值和使用场景:后台财务操作可以更短,内容阅读可以更宽松。超时必须由服务器执行,前端倒计时只能用于提醒。
注销不是“把浏览器页面跳回首页”。服务器必须撤销会话,浏览器端 Cookie 也要用相同的名称、路径和作用域删除。修改密码、发现账号被盗或管理员停用账号时,应能撤销该账号的其他会话,并清楚告诉用户会影响哪些设备。

认证系统最容易出现的误区,是把全部注意力放在登录表单。只要攻击者能让合法会话替他做事,或者拿到会话标识,密码强度就已经不重要了。
Cookie 会根据目标站点自动附带。用户登录了转账网站后,再访问攻击者页面,攻击者可能诱导浏览器向转账接口提交请求。服务器看到的是一个带有效会话 Cookie 的请求,如果它没有验证请求来自用户真实操作,就可能照常执行。
防护首先从语义开始:GET、HEAD 等安全方法不能改变服务端状态。所有状态变更都要经过后端授权,并采用与会话绑定的 CSRF 令牌,或使用经过正确签名并绑定会话的双提交方案。服务还可以核对 Origin,利用请求上下文头阻断明显的跨站非安全请求。
SameSite 很有用,但通常应当是纵深防御。Lax 仍允许部分顶级导航,同一注册域下不受信任的兄弟子域也可能被视为同站,老旧或嵌入式客户端的行为还可能不同。只依靠一个 Cookie 属性,很难覆盖真实部署中的全部入口。
会话固定不是偷走受害者登录后的票,而是让受害者带着攻击者已知的票去登录。如果服务器登录后继续沿用原标识,这张票就从匿名会话升级成了已认证会话。正确做法是只接受服务器自己生成的标识,并在认证成功和权限提升时重新生成;轮换前的标识要作废。
会话标识通常是 Bearer 凭证——持有者就能使用。它可能通过未加密链路、XSS、浏览器扩展、恶意软件、日志、错误监控或复制粘贴泄露。HTTPS、HttpOnly、安全输出编码、内容安全策略、避免在 URL 和日志中记录凭证,分别切断不同的泄露通道。
把会话硬绑定到 IP 地址听起来很安全,但移动网络切换、企业代理和共享出口会制造大量误伤,攻击者也可能处在同一出口。更稳妥的做法是把地址、设备和行为变化作为风险信号:异常时提醒、限制高危动作或要求重新认证,而不是把单个信号当作身份本身。

单机里把会话放在内存很容易。服务一旦扩成十个实例,问题马上出现:登录请求落到 A 实例,下一次刷新落到 B 实例,B 去哪里找这份会话?
常见方案各有代价:
负载均衡器尽量把同一用户送到同一实例。它改造小、局部访问快,但实例故障、扩容和重新分配都会让会话漂移;热点用户还可能造成负载不均。它可以作为过渡手段,不应被误解为真正共享了状态。
所有实例都从共享缓存或数据库读取会话,任一实例都能处理请求,注销和强制下线也容易立即生效。代价是每次请求多一次依赖,存储故障会扩大为登录故障,因此要设计超时、容量、过期、复制和降级策略。会话数据应尽量小,业务资料留在业务系统,不要把共享会话变成第二个用户数据库。
实例验证令牌签名后直接读取有限声明,不必逐次访问会话存储,适合跨服务和多区域验证。但权限变化、账号封禁和主动注销难以立刻反映;密钥轮换、令牌重放和声明膨胀也要处理。所谓“无状态”,只是把部分状态和失效难题转移到了令牌、密钥与生命周期管理上。
实际系统常采用混合方案:短期访问凭证减少热路径查询,刷新或高风险操作仍访问中心状态;或者浏览器只持有服务端会话,由后端代管对下游 API 的令牌。架构选择要围绕撤销速度、故障范围、跨区域延迟和业务风险,而不是追求“完全无状态”的标签。

Token 是“令牌”的泛称,JWT 是一种紧凑的声明封装格式,两者不是同义词。访问令牌可以是数据库里才能查懂的不透明随机串,也可以采用 JWT;会话标识可以放在 Cookie 中,JWT 也可以放在 Cookie 中。传输位置、令牌格式和认证协议是三个不同维度。
常见的三段式 JWT 通常是签名令牌。头部和载荷只是 Base64URL 编码,拿到令牌的人可以读到其中的声明;签名负责证明内容没有被篡改以及来自掌握签名密钥的一方,并不负责保密。身份证号、手机号、内部备注等敏感信息不该因为“有签名”就塞进载荷。
接收方也不能只调用一个“解码”函数。完整验证至少包括:固定允许的算法、验证签名、检查发行者 iss、受众 aud、过期时间 exp 和生效时间 nbf,再确认这个令牌的类型与当前接口用途一致。密钥选择不能盲目信任令牌头部提供的任意地址;不同用途的令牌应分开验证规则和密钥,避免把 ID Token、访问令牌、邮件验证令牌互相替用。
验证访问令牌:
拒绝不在允许列表中的算法
使用受信任发行者的当前密钥验证签名
检查 iss == 预期发行者
检查 aud 包含当前资源服务
检查 exp、nbf 与允许的时钟偏差
检查 token_type == access_token
根据 scope 与当前资源重新做授权这段流程最后仍然是授权。JWT 里的 role: admin 不是宇宙真理,它只是某个发行者在某个时刻做出的声明。资源服务器要判断是否信任该发行者、声明是否发给自己,以及权限是否已经被本地策略收紧。
不透明令牌易于中心撤销,也能避免客户端看到内部声明,但资源服务通常要查询授权服务器或共享存储。JWT 可以本地验证、降低中心查询压力,却会在有效期内保留旧状态。缩短访问令牌寿命能缩小窗口,但会增加刷新频率;维护撤销列表能加快失效,却又引入中心状态。
浏览器里把 Bearer Token 长期放进 localStorage 也不是通用最佳实践。任何成功执行的同源脚本都可能读取它;放进 HttpOnly Cookie 能阻止脚本直接读取,却需要重新面对自动携带带来的 CSRF。面向浏览器的系统经常让后端代管令牌,只给浏览器一个受保护的会话 Cookie,用架构边界减少令牌暴露。

“接入 OAuth 登录”是很常见的说法,但 OAuth 解决的核心问题是授权委托:一个客户端在用户许可下,拿访问令牌去调用资源服务器,而不必拿到用户在授权服务器上的密码。只拿到 OAuth 访问令牌,并不能自动证明“这个人是谁”。
OpenID Connect 在 OAuth 之上增加身份层。它由身份提供方完成用户认证,再向特定客户端签发 ID Token,客户端验证后建立自己的登录会话。因此:访问令牌给资源服务器看,表示允许调用哪些资源;ID Token 给客户端看,描述这次认证和用户标识;授权码只是前端通道里的短期中间结果,要由客户端换取令牌,不能拿去调用业务 API。
以一个课程网站使用统一身份平台登录为例:
事务状态用于把回调与发起请求的浏览器绑定,PKCE 用于阻止被截获的授权码被另一个客户端兑换,OIDC 的随机量用于把 ID Token 与本次认证请求关联。它们名字相近,却防守不同的攻击,不能用一个固定字符串代替,也不能互相想当然地省略。
现代实现优先采用授权码流程并使用 PKCE。把访问令牌直接放在前端重定向结果中的旧式隐式流程扩大了泄露面;让第三方客户端收集用户账号密码的密码模式破坏了授权委托边界,也很难支持 MFA 和抗钓鱼认证,不应再用于新系统。
单点登录让多个系统信任同一个身份提供方。用户在身份提供方已有会话时,访问另一个系统可以快速完成联合认证,不必每个系统都保存一套密码。OIDC 和 SAML 都可以支撑 SSO,OAuth 本身不是 SSO 协议。
代价也很直接:身份提供方不可用,多个业务可能同时无法建立新会话;身份提供方账号被接管,攻击范围会扩展到多个依赖方;全局注销还涉及身份提供方会话和各业务本地会话,单纯删除其中一个 Cookie 未必能全部退出。
每个依赖方仍要验证断言或令牌是不是来自预期身份提供方、是否发给自己、是否属于自己发起的事务,并在本地执行授权。SSO 共享的是认证信任,不是让所有应用共享相同权限。

密码加 PIN 都属于“知道的秘密”,不能因为输入了两次就叫多因素。真正的 MFA 组合不同类型的证明,例如知道的密码、持有的硬件或设备,以及在设备本地验证的生物特征。
短信验证码能挡住一部分只拿到密码的攻击,但会受到号码转移、通信拦截和钓鱼影响;TOTP 验证码避免了短信通道问题,仍可能被实时钓鱼页面转发。基于公钥、绑定站点来源的 WebAuthn 安全密钥或通行密钥能让伪造站点拿不到可用于真实站点的签名,因此具有更强的抗钓鱼能力。
MFA 不只是一张登录页面。系统还要设计设备注册、添加新因素、丢失设备、恢复码、因素替换和管理员协助。添加或移除因素属于高风险操作,应要求近期认证,通知用户,并保留可追踪事件。恢复路径如果只靠一个容易被接管的邮箱,就会把强认证重新降级成单因素。
并非每次浏览都要打断用户。更好的做法是根据风险和操作价值分层:普通阅读沿用现有会话;修改收款账户、导出敏感数据或关闭 MFA 时做阶梯式再认证。这样既缩短高危凭证的使用窗口,也避免用户因频繁提示而机械点击确认。
认证是所有受保护功能的入口,所以它出故障时,影响的不只是登录页。一个可运营的认证系统必须回答三个问题:发生了什么、现在是否仍在发生、怎样让合法用户恢复使用。
应该记录登录成功与失败、限速触发、MFA 挑战与变更、密码和恢复信息修改、会话创建与撤销、权限提升、联合登录失败以及管理员操作。日志包含时间、主体或经过保护的账号标识、事件类型、结果、请求关联标识和必要的风险信号。
密码、完整会话标识、访问令牌、授权码和恢复码绝不能写入日志。日志要限制访问、防篡改、设置保留周期,并把高价值异常接入告警,例如短时间内跨大量账号的密码喷洒、同一恢复流程的集中失败、管理员 MFA 被关闭。记录却不检测,只是把事故经过存了下来。
限速和锁定策略要防止攻击者用失败请求制造大面积拒绝服务。错误提示既要避免账号枚举,也要给合法用户明确的下一步。登录和 MFA 页面要支持键盘、读屏和密码管理器;恢复流程要让用户知道处理进度,却不能泄露账号是否存在。
依赖集中身份提供方时,要明确它故障后的策略。低风险系统可以让已验证且未过期的本地会话继续工作一段受控时间,高风险操作则应在无法确认新鲜身份时拒绝执行。不能为了“高可用”偷偷绕过签名验证,也不能把所有短暂网络抖动都变成全员退出。
上线前可以沿着一条会话逐项检查:
Web 认证最终不是“选 Cookie 还是 JWT”这道单选题。它是一条信任链:最初怎样验证人,随后怎样把身份带进请求,服务怎样限制权限,风险变化后怎样重新确认,最后怎样撤销和追溯。下一步再看访问控制时,你会发现真正困难的问题已经从“这是谁”转向了“他能否对这个具体对象做这件事”。