认证解决「你是谁」,授权解决「你能做什么」。这两件事最容易被随手塞进业务代码里,结果每个服务都要重复实现一遍令牌解析、会话校验与权限判断,接口一多就失控。Nginx 站在所有流量入口,天然适合承担统一的认证边界:它既可以把鉴权完全外置给专用服务,也可以在边缘完成轻量校验,还能作为 OAuth2 代理收拢整个登录流程。本文按「边界怎么划、子请求怎么做、JWT 怎么验、OAuth2 怎么代理、权限怎么透传、与网关怎么分工」的顺序展开,每一节都给出可运行的配置。
1. 认证架构与 Nginx 的定位
一句话总结: Nginx 不应成为身份系统本身,而应成为认证结果的强制校验点与身份信息的透传层。
把认证放在哪一层,取决于三个约束:令牌校验的计算成本、密钥轮换的运维成本、以及下游服务是否信任上游。常见有三种模式。
| 模式 | 校验位置 | 优点 | 代价 |
|---|---|---|---|
| 纯透传 | 后端服务 | 实现简单、语义完整 | 每个服务重复实现、难以统一 |
| 边缘校验 | Nginx 内 | 无额外跳转、延迟低 | 需 njs 或 OpenResty、密钥同步复杂 |
| 子请求校验 | 认证服务 | 逻辑集中、易审计 | 每请求一次内部往返 |
三种模式并非互斥。成熟的做法是分层:Nginx 做「必须通过」的强制校验(签名、过期、受众),认证服务做「需要上下文」的细粒度决策(用户状态、黑名单、资源归属)。
# 分层认证的骨架:边缘先做基础校验,再由子请求做深度决策
http {
# 共享内存存放公钥缓存与吊销列表
lua_shared_dict jwt_keys 10m;
server {
listen 443 ssl;
server_name api.example.com;
# 所有 API 路径统一走认证链
location /api/ {
auth_request /_auth;
auth_request_set $auth_user $upstream_http_x_auth_user;
auth_request_set $auth_roles $upstream_http_x_auth_roles;
proxy_set_header X-Auth-User $auth_user;
proxy_set_header X-Auth-Roles $auth_roles;
proxy_pass http://backend;
}
}
}
这里有两个关键点必须强调。第一,身份头必须由 Nginx 覆盖写入,而不是透传客户端发来的同名头,否则攻击者伪造一个 X-Auth-User: admin 就能越权。第二,后端服务只能监听内网,直接暴露后端等于绕过整个认证层。
2. auth_request 子请求机制
一句话总结: auth_request 把鉴权决策外置为一个内部子请求,2xx 放行、401 或 403 拒绝,并可通过响应头把身份信息注入上游。
auth_request 是 Nginx 内置模块(ngx_http_auth_request_module),默认未编译时需加 --with-http_auth_request_module。它的语义非常克制:对指定 URI 发起一个内部子请求,只关心状态码。
- 返回 2xx:放行,继续处理原请求
- 返回 401:向客户端返回 401(可被
error_page接管) - 返回 403:向客户端返回 403
- 其他状态码:视为 500
子请求不会把请求体传给认证服务,也默认不透传响应体,只读取响应头。这正是它高效的原因。
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
auth_request /_auth_check;
# 把子请求响应头变成变量,再注入上游
auth_request_set $auth_user $upstream_http_x_auth_user;
auth_request_set $auth_scope $upstream_http_x_auth_scope;
auth_request_set $auth_trace $upstream_http_x_request_id;
proxy_set_header X-Auth-User $auth_user;
proxy_set_header X-Auth-Scope $auth_scope;
proxy_set_header X-Request-Id $auth_trace;
proxy_pass http://backend;
}
# 内部认证端点:不对外暴露
location = /_auth_check {
internal;
# 原请求方法与 URI 传给认证服务
proxy_pass http://auth_service/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
# 认证服务应尽快返回,超时即拒绝
proxy_connect_timeout 1s;
proxy_read_timeout 2s;
}
}
三个工程细节值得留意。proxy_pass_request_body off 必须配 Content-Length "",否则上游会等待一个永远不会到达的请求体。认证端点必须加 internal,否则外部可以直接访问 /_auth_check。超时应当短于业务超时,认证服务卡住时宁可快速失败,也不要拖垮整条链路。
如果希望认证失败时跳转登录页而不是返回裸 401,用 error_page 接管:
location /api/ {
auth_request /_auth_check;
error_page 401 = @redirect_login;
proxy_pass http://backend;
}
location @redirect_login {
# 记住原始地址,登录后跳回
return 302 https://sso.example.com/login?next=$scheme://$host$request_uri;
}
3. JWT 本地校验
一句话总结: 在 Nginx 内做 JWT 校验必须同时解决签名验签、过期检查与密钥轮换三个问题,社区版通常借助 njs 或 OpenResty 实现。
NGINX Plus 提供开箱即用的 auth_jwt 指令,能直接校验签名、exp、nbf 与 iss。社区版没有这个模块,需要在 njs 或 Lua 中自己实现。核心逻辑只有四步:切分三段、验签、检查时间声明、提取声明到变量。
# 使用 njs 做 JWT 校验(需 --with-http_js_module 或加载 ngx_http_js_module)
load_module modules/ngx_http_js_module.so;
http {
# njs 里直接读共享内存做密钥缓存
js_shared_dict_zone zone=jwt_keys:1m;
js_import jwt from /etc/nginx/njs/jwt.js;
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
js_content jwt.verify;
}
}
}
// /etc/nginx/njs/jwt.js
var crypto = require('crypto');
function b64urlDecode(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) { s += '='; }
return Buffer.from(s, 'base64').toString('utf8');
}
function verify(r) {
var auth = r.headersIn['Authorization'] || '';
if (!auth.startsWith('Bearer ')) { r.return(401); return; }
var token = auth.slice(7);
var parts = token.split('.');
if (parts.length !== 3) { r.return(401); return; }
// 生产环境应支持 kid 到多公钥的映射,并做本地缓存
var pubkey = r.variables.jwt_public_key;
var signingInput = parts[0] + '.' + parts[1];
var verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signingInput);
if (!verifier.verify(pubkey, Buffer.from(parts[2], 'base64url'))) { r.return(401); return; }
var payload = JSON.parse(b64urlDecode(parts[1]));
var now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) { r.return(401); return; }
if (payload.nbf && payload.nbf > now) { r.return(401); return; }
// 把身份注入上游
r.headersOut['X-Auth-User'] = payload.sub;
r.headersOut['X-Auth-Roles'] = (payload.roles || []).join(',');
r.return(204);
}
export default { verify };
密钥轮换是最容易被忽略的一环。若把公钥写死在配置里,IdP 换密钥那天全站 401。正确做法是按 kid 拉取 JWKS 并缓存,缓存时间略短于令牌有效期。
# 从 JWKS 端点拉取公钥并生成 nginx 可读的缓存文件
curl -s https://idp.example.com/.well-known/jwks.json \
| jq -r '.keys[] | "\(.kid) \(.n)"' > /etc/nginx/jwt_keys.txt
# 用 systemd timer 定期刷新,Nginx 只需重载
systemctl enable --now jwt-keys-refresh.timer
需要注意,JWT 校验只解决「令牌可信」,不解决「令牌是否已被吊销」。若业务需要即时登出,必须叠加短有效期加刷新令牌,或引入吊销列表查询。
4. OAuth2 代理与令牌流程
一句话总结: OAuth2 代理把浏览器重定向、授权码交换与令牌刷新收拢到 Nginx,使后端服务只面对已认证的请求。
标准 OAuth2 授权码流程有多个重定向环节,让每个后端服务都实现一遍并不现实。把流程放在 Nginx,后端只接收已经带上可信身份的请求,这就是 OAuth2 代理(oauth2-proxy 或 lua-resty-openidc)的思路。
# oauth2-proxy 作为 auth_request 的认证端点
location = /_auth_check {
internal;
proxy_pass http://oauth2_proxy:4180/oauth2/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
}
# 登录与回调路径直接交给代理
location /oauth2/ {
proxy_pass http://oauth2_proxy:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
auth_request /_auth_check;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
auth_request_set $jwt $upstream_http_x_auth_request_access_token;
proxy_set_header X-Auth-User $user;
proxy_set_header X-Auth-Email $email;
proxy_pass http://backend;
}
若希望 Nginx 自己完成令牌交换与刷新,可以用 lua-resty-openidc:
location /private/ {
access_by_lua_block {
local opts = {
discovery = "https://idp.example.com/.well-known/openid-configuration",
client_id = "nginx-gateway",
client_secret = os.getenv("OIDC_CLIENT_SECRET"),
redirect_uri = "https://api.example.com/redirect_uri",
ssl_verify = "yes",
-- 令牌校验通过后注入头
accept_none_alg = false,
}
local res, err = require("resty.openidc").authenticate(opts)
if err then
ngx.status = 401
ngx.say(err)
ngx.exit(401)
end
ngx.req.set_header("X-Auth-User", res.id_token.sub)
ngx.req.set_header("X-Auth-Email", res.id_token.email)
}
proxy_pass http://backend;
}
令牌刷新的边界要设计清楚:Nginx 只负责「拿到有效令牌」,业务侧不感知刷新过程;刷新令牌存在服务端会话里,绝不落到浏览器。
5. 授权决策与权限透传
一句话总结: 认证只解决身份,授权必须基于路由、角色与资源三个维度在 Nginx 或后端完成二次判定。
认证通过不代表可以访问一切。授权至少有三个维度:路由维度(这条路径允许哪些角色)、角色维度(该角色有哪些操作)、资源维度(这条记录属不属于他)。前两个适合放在 Nginx,第三个必须交给后端。
# 基于 map 的路由级 RBAC
map $auth_roles $allow_admin {
default 0;
"~*(^|,)admin(,|$)" 1;
}
server {
location /api/admin/ {
auth_request /_auth_check;
auth_request_set $auth_roles $upstream_http_x_auth_roles;
if ($allow_admin = 0) {
return 403;
}
proxy_pass http://backend;
}
# 只读接口:任何已认证用户
location /api/read/ {
auth_request /_auth_check;
proxy_pass http://backend;
}
}
if 在 location 里是「邪恶的」这个说法针对的是 if 中混用 proxy_pass 等指令的场景;用在 return 上是安全的,因为它在 rewrite 阶段短路执行。
资源级授权则通过把身份继续透传,让后端自己判断:
# 后端拿到身份后做资源归属校验
curl -s -H "X-Auth-User: u-1024" https://api.example.com/api/orders/7788
# 后端逻辑:order.owner_id == X-Auth-User ? 200 : 403
一个常见的错误是「Nginx 校验了角色,后端就无条件信任」。一旦有内部调用绕过 Nginx,权限就形同虚设。因此后端必须把身份头当作未经校验的输入,仅在确认请求来自网关(如 mTLS 或内网 ACL)后才信任。
6. 与 API 网关及外部 IdP 协作
一句话总结: 在多层网关体系中必须明确唯一的认证边界,其余层只做可信头透传并严格防止伪造。
真实系统里常见三层结构:CDN 边缘、Nginx 接入层、业务网关。如果每层都做一遍认证,会出现令牌重复校验、身份头互相覆盖的问题。清晰的分工是:只在一层做真实验证,其余层只做透传与一致性检查。
# 接入层:唯一认证边界
location /api/ {
auth_request /_auth_check;
auth_request_set $auth_user $upstream_http_x_auth_user;
# 覆盖式写入,并清除客户端伪造的同名头
proxy_set_header X-Auth-User $auth_user;
proxy_set_header X-Auth-Verified "nginx-gateway";
proxy_set_header X-Client-Real-IP $remote_addr;
proxy_pass http://business_gateway;
}
业务网关侧应当校验一个只有内部知道的标记(如 X-Auth-Verified 配合来源 IP 白名单),确认请求确实来自接入层。
与外部 IdP 协作时,还有两点容易踩坑。第一,JWKS 与 Issuer 必须严格匹配:iss 校验不做,攻击者可以用另一个租户签发的合法令牌冒充。第二,aud 必须校验:同一 IdP 为多个应用签发令牌,不校验受众意味着 A 应用的令牌能访问 B 应用。
{
"iss": "https://idp.example.com/",
"aud": "api.example.com",
"sub": "u-1024",
"exp": 1790000000,
"roles": ["user", "billing"]
}
7. 常见陷阱与排错
一句话总结: 认证问题九成出在头伪造、缓存与子请求边界上,排查要按请求链路逐跳验证。
陷阱一:身份头伪造。 后端直接信任 X-Auth-User,而 Nginx 用 proxy_set_header 是覆盖式写入,看似安全;但若某条 location 漏配,请求就会带着客户端原始头直达后端。对策是后端也做来源校验。
陷阱二:认证结果被缓存。 auth_request 的结果若被上游缓存复用,不同用户会拿到同一个响应。必须确保带身份的响应 Cache-Control: private。
陷阱三:子请求超时导致雪崩。 认证服务抖动时,所有请求都在等 2 秒,连接池迅速耗尽。对策是给认证端点设置独立且更短的超时,并配合熔断。
陷阱四:预检请求被拦截。 浏览器的 CORS 预检 OPTIONS 不带 Authorization,若认证端点也校验它,跨域请求会全挂。
# 放行预检请求
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
auth_request /_auth_check;
proxy_pass http://backend;
}
排错时的顺序建议是:先看 Nginx 访问日志确认状态码,再看 error_log 中的 auth request 相关记录,然后用 curl -H "Authorization: Bearer ..." https://api.example.com/_auth_check 直接打认证端点,最后核对后端收到的头。逐跳验证比盯着单点猜测高效得多。
# 第一步:直接打认证端点,确认令牌本身可用
curl -si https://api.example.com/_auth_check -H "Authorization: Bearer $TOKEN"
# 第二步:打业务接口,确认头已注入
curl -si https://api.example.com/api/me -H "Authorization: Bearer $TOKEN"
# 第三步:在后端应用日志中输出 X-Auth-User 与 X-Auth-Verified 做比对
8. 总结
| 环节 | 要点 |
|---|---|
| 边界划分 | 只在一层做真实校验,Nginx 做强制校验点与身份透传 |
| 子请求 | auth_request 只看状态码,内部端点必须 internal,超时短于业务 |
| JWT 校验 | 验签、exp、nbf、iss、aud 五项缺一不可,公钥按 kid 缓存轮换 |
| OAuth2 代理 | 重定向与刷新收拢到网关,刷新令牌不下发浏览器 |
| 授权分层 | 路由与角色在 Nginx,资源归属必须回到后端判定 |
| 头安全 | 覆盖式写入并清除伪造头,后端校验请求来源 |
| 排错 | 逐跳验证:认证端点、业务接口、后端收到的头 |
认证授权的核心不是「把令牌验过」,而是「明确谁在什么位置做哪一种判断」。只要边界清晰、身份头不可伪造、密钥可轮换,这套架构就能长期稳定。下一篇我们转向路由本身,讨论 rewrite 与 location 匹配的优先级与陷阱,那是配置写错最多的地方。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。