认证(Authentication,你是谁)与授权(Authorization,你能做什么)是两件常被混为一谈的事。在传统的服务端会话模型里,两者由一个 Session ID 承载:浏览器存 Cookie,服务端查 Session 表,从表里读出用户身份与角色。这套机制简单可靠,但有两个硬伤——水平扩展需要共享 Session 存储,移动端与跨域场景下 Cookie 又很不方便。
JWT(JSON Web Token)的流行正是为了解决这些问题:把身份与权限信息签名后放在令牌里,服务端无需查表即可验证,天然适合无状态的 API 与多服务架构。但「无需查表」也意味着「无法立即撤销」,这个根本性的取舍衍生出了一整套工程实践——什么时候该用 JWT,什么时候该用不透明令牌,吊销如何设计,密钥如何轮换。
一、认证与授权的边界
1.1 两种模型对比
| 维度 | 服务端会话 | 无状态 JWT |
|---|---|---|
| 状态存储 | 服务端(Redis/DB) | 客户端(令牌本身) |
| 水平扩展 | 需共享存储 | 天然支持 |
| 撤销 | 删 Session 即生效 | 需额外机制(黑名单/版本号) |
| 跨域/移动端 | Cookie 受限 | Authorization 头通用 |
| 令牌大小 | 几十字节 | 几百字节到数 KB |
| 信息泄露风险 | 低(只存 ID) | 高(payload 是明文 Base64) |
| 适用 | 单体 Web 应用 | 微服务、API、移动端 |
1.2 常见误区
- 把 JWT 当加密:JWT 的 payload 只是 Base64URL 编码,任何人都能解开。绝不放密码、身份证号等敏感信息;
- 把 JWT 当会话的等价替代:JWT 的强项是无状态校验,弱项是撤销。需要「立刻踢下线」的场景要用混合方案;
- 过期时间设得过长:很多系统把
exp设成 30 天,等于把长期凭证交给了客户端。正确做法是短寿命 access token + 长寿命 refresh token。
二、JWT 结构与签名算法
2.1 三段式结构
一个 JWT 由三部分用 . 连接:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjMiLCJleHAiOjE3Njg4fQ . dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Header (Base64URL) Payload (Base64URL) Signature
| 部分 | 内容 | 是否可读 |
|---|---|---|
| Header | {"alg": "HS256", "typ": "JWT"} | 明文可读 |
| Payload | 标准声明 + 自定义声明 | 明文可读 |
| Signature | 对前两段的签名 | 不可伪造(但可重放) |
2.2 标准声明
| 声明 | 名称 | 说明 |
|---|---|---|
iss | Issuer | 签发者,用于校验来源 |
sub | Subject | 主体,通常是用户 ID |
aud | Audience | 接收方,防止令牌被用于其他服务 |
exp | Expiration Time | 过期时间(Unix 秒) |
nbf | Not Before | 生效时间 |
iat | Issued At | 签发时间 |
jti | JWT ID | 令牌唯一标识,用于吊销与防重放 |
sub、exp、iss 是必须校验的三个声明。省略 aud 校验会让 A 服务的令牌在 B 服务上畅通无阻。
2.3 签名算法选型
| 算法 | 类型 | 密钥 | 适用场景 |
|---|---|---|---|
| HS256 | HMAC-SHA256 | 对称共享密钥 | 单一服务自签自验 |
| RS256 | RSA-SHA256 | 私钥签 / 公钥验 | 多服务验证、需公开公钥 |
| ES256 | ECDSA-P256 | 私钥签 / 公钥验 | 同上,签名更短 |
| EdDSA | Ed25519 | 私钥签 / 公钥验 | 现代选择,性能与安全兼顾 |
| none | 无签名 | 无 | 绝不允许 |
算法选择的判据是「谁需要验证」:如果只有签发者自己验证(单体应用或网关统一验证),HS256 足够且更快;如果多个服务需要独立验证令牌,必须用非对称算法,否则共享密钥的扩散面等于把签发权交给了每一个验证方。
2.4 签名与验证的本质
signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
验证方用同样的密钥重算签名并比对。这里的三个关键点:
- 算法由 header 声明,但验证方必须白名单校验——否则攻击者可以把
alg改成none或把 RS256 改成 HS256(用公钥当 HMAC 密钥),这就是著名的「算法混淆攻击」; - 签名保证完整性,不保证机密性——payload 是明文;
- 签名不防重放——同一个令牌在过期前可以被无限次使用,防重放需要
jti+ 服务端记录。
三、Guardian 配置与 Pipeline
3.1 安装与基础配置
# mix.exs
{:guardian, "~> 2.3"}
# config/config.exs
config :my_app, MyApp.Guardian,
issuer: "my_app",
secret_key: System.get_env("GUARDIAN_SECRET_KEY"),
ttl: {1, :hour}
密钥必须来自环境变量或密钥管理服务,绝不能提交进仓库。mix guardian.gen.secret 可以生成一个合适的随机密钥。
3.2 定义 Guardian 模块
defmodule MyApp.Guardian do
use Guardian, otp_app: :my_app
alias MyApp.Accounts
@impl true
def subject_for_token(%{id: id}, _claims), do: {:ok, to_string(id)}
def subject_for_token(_, _), do: {:error, :invalid_resource}
@impl true
def resource_from_claims(%{"sub" => id}) do
case Accounts.get_user(id) do
nil -> {:error, :resource_not_found}
user -> {:ok, user}
end
end
def resource_from_claims(_), do: {:error, :invalid_claims}
end
这两个回调定义了「资源 ↔ 令牌」的双向映射。subject_for_token/2 在签发时把用户转成 sub,resource_from_claims/1 在验证时把 sub 还原成用户。注意 resource_from_claims/1 每次都查数据库——这正是「JWT 无状态」在实践中被打破的地方,也是吊销能生效的原因。
3.3 Phoenix Pipeline
# lib/my_app_web/router.ex
pipeline :api_auth do
plug Guardian.Plug.Pipeline,
module: MyApp.Guardian,
error_handler: MyAppWeb.AuthErrorHandler
plug Guardian.Plug.VerifyHeader, scheme: "Bearer", realm: "Bearer"
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource, allow_nil: false
end
scope "/api/v1", MyAppWeb.API do
pipe_through [:api, :api_auth]
resources "/orders", OrderController
end
| 插件 | 作用 |
|---|---|
Pipeline | 指定使用的 Guardian 模块与错误处理器 |
VerifyHeader | 从 Authorization: Bearer xxx 中提取令牌并验证签名 |
VerifySession | 从 Session 中提取令牌(浏览器场景) |
EnsureAuthenticated | 无有效令牌时中断请求,返回 401 |
LoadResource | 调用 resource_from_claims/1,把用户放进 conn.assigns |
EnsureAuthenticated + claims | 检查特定声明(如 role)以做授权 |
3.4 错误处理
defmodule MyAppWeb.AuthErrorHandler do
import Plug.Conn
@behaviour Guardian.Plug.ErrorHandler
@impl true
def auth_error(conn, {type, _reason}, _opts) do
body = Jason.encode!(%{error: to_string(type)})
conn
|> put_resp_content_type("application/json")
|> send_resp(401, body)
end
end
错误信息要保持模糊——返回「token expired」还是「invalid signature」对合法客户端无差别,却给攻击者提供了免费的探测反馈。
四、Token 签发、验证与刷新
4.1 签发
# 基础签发
{:ok, token, claims} = MyApp.Guardian.encode_and_sign(user)
# 带自定义声明与过期时间
{:ok, token, _claims} =
MyApp.Guardian.encode_and_sign(user, %{"role" => user.role, "org" => user.org_id},
ttl: {15, :minute},
token_type: "access")
# 刷新令牌:长寿命,只用于换取新的 access token
{:ok, refresh, _} = MyApp.Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: {7, :day})
token_type 会在 claims 中写入 typ 字段。access 与 refresh 必须用不同的 type,否则攻击者可以用 refresh token 直接访问业务接口。
4.2 验证
# 只验签与声明,不查库
{:ok, claims} = MyApp.Guardian.decode_and_verify(token)
# 验签 + 还原资源
{:ok, user, claims} = MyApp.Guardian.resource_from_token(token)
# 在 Plug 之外手动验证(如 WebSocket 握手)
case MyApp.Guardian.resource_from_token(token) do
{:ok, user, _} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
WebSocket 场景下无法使用 Plug Pipeline,需要在 Phoenix.Socket.connect/3 中手动验证,见 https://plumephp.com/elixir-phoenix-channels-realtime/ 的 Socket 认证章节。
4.3 刷新与交换
# 用 refresh token 换一对新的 access + refresh
{:ok, _old, {new_access, _}} =
MyApp.Guardian.exchange(refresh_token, "refresh", "access", ttl: {15, :minute})
刷新时必须做轮换(rotation):每次使用 refresh token 都签发新的 refresh 并作废旧的。这样一旦 refresh token 泄露,攻击者使用时会导致合法用户的令牌失效,从而被检测到。若不做轮换,一个泄露的 refresh token 可以安静地使用 7 天。
4.4 令牌传递方式
| 方式 | 优点 | 风险 |
|---|---|---|
Authorization: Bearer 头 | 无 CSRF 风险,跨域友好 | 需要客户端 JS 存储(XSS 可窃取) |
| HttpOnly Cookie | JS 无法读取(抗 XSS) | 需 CSRF 防护,跨域麻烦 |
| URL 参数 | 便于分享 | 极不安全,会进日志与 Referer |
| localStorage | 简单 | XSS 可窃取 |
推荐组合:access token 放内存(JS 变量),refresh token 放 HttpOnly + Secure + SameSite=Strict Cookie。这样 XSS 拿不到长期凭证,CSRF 也被 SameSite 挡住。
五、Phoenix.Token、吊销与密钥轮换
5.1 Phoenix.Token 的适用边界
Phoenix.Token 不是 JWT,它是 Phoenix 内置的签名令牌工具,用于「一次性、单一用途」的场景:
# 邮箱验证 / 密码重置
token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user confirmation", user.id, max_age: 86_400)
case Phoenix.Token.verify(MyAppWeb.Endpoint, "user confirmation", token, max_age: 86_400) do
{:ok, user_id} -> confirm(user_id)
{:error, :expired} -> {:error, :token_expired}
{:error, :invalid} -> {:error, :invalid_token}
end
| 维度 | Guardian(JWT) | Phoenix.Token |
|---|---|---|
| 标准 | RFC 7519 | Phoenix 私有格式 |
| 跨服务验证 | 支持(非对称算法) | 不支持(共享 secret_key_base) |
| 自定义声明 | 支持 | 不支持 |
| 大小 | 较大 | 较小 |
| 用途 | API 认证 | 邮箱验证、密码重置、临时签名 URL |
判据很简单:需要跨服务或需要携带声明,用 JWT;只是给用户一个带签名的临时凭证,用 Phoenix.Token。密码重置链接用 JWT 是过度设计,而 API 认证用 Phoenix.Token 则无法与其他服务互通。
5.2 吊销的三种方案
JWT 天然不可撤销,实践中用三种方案弥补:
| 方案 | 实现 | 代价 | 生效延迟 |
|---|---|---|---|
| 短寿命 + 刷新 | access token 15 分钟 | 客户端需处理刷新 | ≤ 令牌寿命 |
| 黑名单 | 把吊销的 jti 存入 Redis,验证时查 | 每次请求一次查询 | 立即 |
| 版本号 | 用户表加 token_version,令牌携带该值 | 每次请求一次查询 | 立即 |
黑名单实现(基于 Guardian.DB 或自建):
defmodule MyApp.TokenRegistry do
def revoke(jti, exp) do
# TTL 设为令牌剩余寿命,到期自动清理
ttl = exp - System.system_time(:second)
Redix.command!(:redis, ["SET", "revoked:#{jti}", "1", "EX", max(ttl, 1)])
end
def revoked?(jti), do: Redix.command!(:redis, ["EXISTS", "revoked:#{jti}"]) == 1
end
版本号方案更简洁:用户表加一列 token_version,签发时写入 claims,验证时比对。改密码或「登出所有设备」只需把版本号加一,所有旧令牌立即失效——一次数据库查询(或用缓存)解决全部吊销需求。
5.3 密钥轮换
密钥轮换的目标是「不中断服务地更换签名密钥」。对称算法(HS256)做不到无缝轮换——密钥换了,旧令牌全部失效。正确做法是改用非对称算法并发布 JWKS:
签名方:用私钥签发,header 中带 kid(密钥 ID)
验证方:按 kid 从 JWKS 端点取对应公钥验证
轮换:新令牌用新 kid 签发,旧公钥保留一个令牌寿命周期后再移除
# 用 JOSE 手动验证(Guardian 底层也是 JOSE)
def verify_with_jwks(token) do
{_, %{"kid" => kid}} = JOSE.JWT.peek_protected(token)
jwk = fetch_public_key(kid) # 从 JWKS 缓存获取
{true, %JOSE.JWT{fields: claims}, _} = JOSE.JWT.verify_strict(jwk, ["RS256"], token)
{:ok, claims}
end
关键约束:验证方必须显式白名单允许的算法(verify_strict 的第二个参数),这是防算法混淆攻击的唯一可靠手段。
六、权限策略与常见攻击面
6.1 从认证到授权
认证解决「你是谁」,授权解决「你能不能做这件事」。最小可用方案是把角色放进 claims:
plug :ensure_role, ["admin"] when action in [:delete]
defp ensure_role(conn, roles) do
claims = Guardian.Plug.current_claims(conn)
if claims["role"] in roles do
conn
else
conn |> put_status(403) |> halt()
end
end
但角色很快会不够用——「只能删除自己部门的订单」这类规则需要资源级判断。此时应引入策略层:
defmodule MyApp.Policy do
def authorize(:delete, %Order{} = order, %User{} = user) do
order.user_id == user.id or user.role == "admin"
end
end
权限判断必须在服务端每次执行,绝不能只靠前端隐藏按钮。把 claims 里的角色当作缓存的提示可以,当作唯一依据不行。
6.2 攻击面清单
| 攻击 | 原理 | 防御 |
|---|---|---|
alg: none | 把算法改成 none,签名校验被跳过 | 验证方白名单算法 |
| 算法混淆 | RS256 改 HS256,用公开的公钥当 HMAC 密钥 | 白名单算法 + 区分密钥类型 |
| 重放 | 截获令牌后重复使用 | 短 exp + jti 黑名单 + TLS |
| 令牌泄露 | XSS 窃取 localStorage | HttpOnly Cookie + CSP |
| CSRF | Cookie 自动携带,跨站发起请求 | SameSite + CSRF Token |
| 声明注入 | 篡改 payload(若签名校验缺失) | 永远校验签名 |
| 敏感信息泄露 | payload 明文 | 不放敏感数据 |
| 弱密钥 | 密钥过短可被暴力破解 | 至少 256 位随机密钥 |
6.3 与加密体系的配合
JWT 只解决「凭证的完整性」,不解决「传输的机密性」与「存储的机密性」。完整的安全体系还需要:
- 传输层:全站 HTTPS,TLS 配置参考 https://plumephp.com/erlang-security-crypto/ 的 TLS 章节;
- 密码存储:Argon2id 或 bcrypt,绝不用 SHA256 直接哈希;
- 密钥管理:密钥进 KMS 或环境变量,定期轮换;
- 审计日志:记录签发、刷新、吊销事件,异常模式(同一
sub短时间多次刷新)应告警。
七、最佳实践与总结
- access token 短寿命,refresh token 长寿命且轮换:15 分钟 + 7 天是常见起点,轮换是检测泄露的关键;
- 验证方必须白名单算法:
alg: none与算法混淆是 JWT 历史上最严重的两类漏洞,用verify_strict一次性堵住; - payload 不放敏感信息:Base64 不是加密,任何能拿到令牌的人都能读到全部声明;
- 区分 access 与 refresh 的
typ:用token_type隔离用途,防止 refresh token 被当作 access token 使用; - 优先用版本号做吊销:比黑名单更简洁,一次比对即可让全部旧令牌失效;
- 需要跨服务就上非对称算法 + JWKS:对称密钥在多方之间共享会无限扩大信任面,且无法平滑轮换;
- 单一用途的临时凭证用
Phoenix.Token:密码重置、邮箱验证不必套用 JWT 的复杂度; - 认证与授权分层实现:Guardian 负责「你是谁」,策略模块负责「你能不能做」,两者不要混在一个 Plug 里;
- 令牌存储位置决定威胁模型:内存 + HttpOnly Cookie 的组合在 XSS 与 CSRF 之间取得了最好的平衡。
JWT 不是银弹,它的价值与代价同样鲜明:无状态校验带来了水平扩展与跨服务互认,也带来了撤销困难与信息暴露。真正成熟的方案从来不是「用不用 JWT」,而是根据业务对「撤销时效」和「跨服务验证」的要求,组合出短寿命令牌、刷新轮换、版本号吊销、非对称签名这几块积木。把这套组合与 Phoenix 的 Plug 管道、OTP 的监督模型结合起来,你就能得到一个既安全又易于演进的认证体系。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。