Nginx auth_request:把鉴权从业务代码里彻底拆出去
很多站长的鉴权是这么写的:每个 PHP 接口开头都来一段 if (!isset($_SESSION['uid'])) { die('unauthorized'); }。少量页面没问题,等站点长大、接口变多,这段代码就会被复制到几十个文件里,改一次鉴权规则要全站搜一遍。Nginx 的 auth_request 模块提供了另一条路:让 Nginx 在转发请求之前,先发一个子请求去问鉴权服务「这个人能进吗」,拿到 2xx 就放行,拿到 401/403 就直接拦下,业务代码连执行的机会都没有。
这篇文章讲清楚 auth_request 的工作机制、一个可直接落地的内部鉴权服务、鉴权结果的缓存,以及那几个一定会踩的坑。
一、auth_request 到底做了什么
当你在一个 location 里写下 auth_request /auth;,Nginx 收到请求时会先向本机一个内部地址(这里是 /auth)发起一个子请求。这个子请求:
- 沿用原请求的方法吗?不是——子请求默认用 GET,并且不会带上原请求的 body(这点非常关键,后面会说)。
- 会把原请求的头部带过去吗?默认只带一部分,但你可以用
auth_request_set和 proxy_set_header 主动传。 - 只看状态码:2xx 表示通过,401 表示未认证,403 表示已认证但无权限,其他状态码一律当作 500 处理(拒绝并报错)。
子请求返回 2xx 后,Nginx 才会把原请求交给后面的后端(PHP、Python、静态文件都行)。整个过程如图一条流水线:客户端 → Nginx → 鉴权子请求 → (2xx)→ 业务后端。
二、最小可用的 Nginx 配置
假设我们有一个内部鉴权服务监听在 127.0.0.1:9001,路径 /verify。
server {
listen 443 ssl http2;
server_name www.example.com;
# 鉴权子请求走的内部接口
location = /auth {
internal; # 只允许内部调用,外部直接访问返回 404
proxy_pass http://127.0.0.1:9001/verify;
proxy_pass_request_body off; # 不传 body,鉴权不需要
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
}
# 需要保护的业务区域
location /admin/ {
auth_request /auth;
# 把鉴权服务返回的头部暴露给业务后端
auth_request_set $auth_user $upstream_http_x_auth_user;
auth_request_set $auth_role $upstream_http_x_auth_role;
proxy_set_header X-Auth-User $auth_user;
proxy_set_header X-Auth-Role $auth_role;
proxy_pass http://127.0.0.1:9000;
}
}
要点解析:
internal;必须加。它保证/auth只能被 Nginx 内部发起,外部用户 curl 访问/auth会得到 404,避免鉴权接口被外部直接探测。proxy_pass_request_body off配合空的Content-Length,避免把用户上传的大文件 body 也转给鉴权服务,白白浪费内存和带宽。auth_request_set是精髓:它能把鉴权服务响应里的头部(比如X-Auth-User)取出来,通过proxy_set_header传给真正的业务后端。于是业务代码只要读$_SERVER['HTTP_X_AUTH_USER']就知道是谁,再也不用自己解析 token 了。
三、写一个够用的鉴权服务
鉴权服务不用很复杂,核心逻辑是:读 Cookie 或 Authorization 头里的 token → 查会话表 → 命中就返回 200 并带上用户信息头,否则返回 401。下面是一个 20 行左右的 Python 版本,用 Flask 起在 9001 端口:
from flask import Flask, request, Response
app = Flask(__name__)
# 生产环境换成 Redis / 数据库查询
SESSIONS = {
"token-abc-123": {"user": "alice", "role": "admin"},
"token-xyz-789": {"user": "bob", "role": "editor"},
}
@app.route("/verify")
def verify():
token = None
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
if not token:
token = request.cookies.get("sid")
sess = SESSIONS.get(token or "")
if not sess:
return Response("unauthorized", status=401)
uri = request.headers.get("X-Original-URI", "")
# 细粒度:只有 admin 能进 /admin/ 下的 settings
if "/admin/settings" in uri and sess["role"] != "admin":
return Response("forbidden", status=403)
resp = Response("ok", status=200)
resp.headers["X-Auth-User"] = sess["user"]
resp.headers["X-Auth-Role"] = sess["role"]
return resp
if __name__ == "__main__":
app.run(host="127.0.0.1", port=9001)
注意它返回的 X-Auth-User 头部,正是前面 Nginx 里 auth_request_set $auth_user $upstream_http_x_auth_user 要抓的东西。命名规则是固定的:Nginx 把响应头的前缀 X-Auth- 转小写、把连字符换成下划线,拼成 $upstream_http_x_auth_user。
四、性能:别让鉴权拖垮每个请求
auth_request 的最大代价是每个受保护请求都会多一次内部 HTTP 往返。如果鉴权服务每次都查数据库,QPS 一高就成了瓶颈。三个优化方向:
- 鉴权服务本身用内存或 Redis 存会话,别落 MySQL。会话读是典型的 KV 场景。
- 缓存鉴权结果。Nginx 支持把子请求的结果交给
proxy_cache,但默认 auth_request 不缓存。更简单稳妥的做法是在鉴权服务里缓存。 - 按需保护。只对真正需要鉴权的
location加auth_request,静态资源(css/js/图片)不要走鉴权,否则一个页面几十个资源就是几十次子请求。把受保护范围用 location 精确圈出来:
# 静态资源不受保护,直出
location ~* \.(css|js|png|jpg|webp|woff2)$ {
expires 30d;
add_header Cache-Control "public";
}
# 只保护动态接口与后台
location /api/ {
auth_request /auth;
proxy_pass http://127.0.0.1:9000;
}
五、五个一定会踩的坑
坑 1:子请求丢失请求体。 前面说过,auth_request 的子请求不带 body。如果你指望鉴权服务去读 body 里的签名来验签,会发现 body 是空的。解法是把签名放在 header 或 query 里,而不是 body。
坑 2:Cookie 没传过去。 默认子请求会带上原请求的 Cookie,但如果你在 /auth 里又手动 proxy_set_header Cookie "",就把会话 Cookie 清掉了,鉴权永远失败。要么别覆盖,要么像示例里那样显式 proxy_set_header Cookie $http_cookie;。
坑 3:401 没有附带 WWW-Authenticate 导致浏览器不弹登录框。 如果希望走 HTTP Basic 弹窗,鉴权服务返回 401 时要带 WWW-Authenticate: Basic realm="Restricted" 头,且要 add_header 透传给客户端。auth_request 本身不会自动加这个头。
坑 4:error_page 404 把 401 也吞了。 如果站点配了全局 error_page 404 /404.html;,注意 401/403 不要被错误地重定向到 404,否则用户看到的是「页面不存在」而不是「请登录」,体验很怪。用 error_page 401 = @login_redirect; 单独处理。
坑 5:内部地址没加 internal,被外部绕过。 这是安全问题。如果 /auth 没标 internal,外部攻击者可以直接请求 /auth 探测你鉴权服务的返回,甚至利用某些实现缺陷。一定加上。
六、把鉴权结果缓存起来,别再每次查库
前面提到鉴权服务的瓶颈在会话查询。更进一步的做法是利用 Nginx 的 auth_request 配合本地缓存,把「同一 token 短时间内的重复鉴权」直接挡在鉴权服务之外。虽然 auth_request 本身不走 proxy_cache,但我们可以用一个笨办法:在鉴权服务里加一层内存缓存。下面用 Python 的 functools.lru_cache 快速实现,设一个 30 秒的过期窗口:
import time, functools
_cache = {}
def cached_verify(token):
now = time.time()
hit = _cache.get(token)
if hit and hit[0] > now:
return hit[1]
# 真正查会话(这里用字典模拟 Redis)
sess = SESSIONS.get(token)
_cache[token] = (now + 30, sess) # 缓存 30 秒
return sess这样在 30 秒内,同一个用户刷一百次页面只会查一次会话。代价是「登出后最多 30 秒内仍有效」——对后台管理场景完全可以接受,对高安全场景就把窗口调成 5 秒甚至干脆不缓存。
七、多个 location 共用一套鉴权规则
真实站点往往不止一个受保护目录:/admin/、/api/、/files/ 可能都要鉴权,但要求的角色不同。与其在每个 location 里重复写 auth_request /auth,不如把共同配置抽成 include 片段。Nginx 虽然没有「继承 location 配置」的概念,但可以用 include 复用同一份片段:
# /etc/nginx/snippets/auth-common.conf
auth_request /auth;
auth_request_set $auth_user $upstream_http_x_auth_user;
auth_request_set $auth_role $upstream_http_x_auth_role;
proxy_set_header X-Auth-User $auth_user;
proxy_set_header X-Auth-Role $auth_role;
# 在 server 块里复用
location /admin/ {
include snippets/auth-common.conf;
proxy_pass http://127.0.0.1:9000;
}
location /api/ {
include snippets/auth-common.conf;
proxy_pass http://127.0.0.1:9001;
}
角色区分交给鉴权服务内部判断——它已经拿到了 X-Original-URI,可以根据访问路径决定不同的权限规则(比如 /admin/settings 只有 admin 能进)。把「谁在访问哪个路径」集中在一个服务里判断,比把规则散落在 Nginx 配置里更易维护。
八、常见报错与排查路径
配 auth_request 时最常看到两个错误:auth request unexpected status: 500 和 auth request unexpected status: 404。
- 500:多数是鉴权服务本身挂了或返回了非 2xx/401/403 的状态码。Nginx 把「无法识别的状态码」一律当 500。先确认
curl -i http://127.0.0.1:9001/verify能不能通,再检查是不是返回了 302 跳转。 - 404:通常是
proxy_pass地址写错,或者鉴权服务的路由没匹配上。注意location = /auth里的proxy_pass http://127.0.0.1:9001/verify;,末尾带路径时 Nginx 会把/auth替换成/verify;如果写成http://127.0.0.1:9001;(不带路径),则会把/auth原样拼上去,变成请求/auth,那自然就 404 了。
排查时打开 error_log 的 info 级别,能看到子请求的完整过程:
error_log /var/log/nginx/error.log info;
# 然后 tail -f 观察,能看到 "http auth request" 相关行
九、用 auth_request 做接口签名验证
除了会话鉴权,auth_request 还有一个很实用的场景:对外开放 API 的签名校验。假设你把站点部分数据通过 API 暴露给合作方,要求每个请求带上 X-Sign(对参数 + 密钥做 HMAC)和 X-Timestamp。这套校验完全可以在 Nginx 层前置完成,业务后端只管处理合法请求:
location /open-api/ {
auth_request /auth-sign;
proxy_pass http://127.0.0.1:9002;
}
location = /auth-sign {
internal;
proxy_pass http://127.0.0.1:9001/verify_sign;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# 把签名所需信息原样转给鉴权服务
proxy_set_header X-Sign $http_x_sign;
proxy_set_header X-Timestamp $http_x_timestamp;
proxy_set_header X-App-Key $http_x_app_key;
proxy_set_header X-Original-URI $request_uri;
}
鉴权服务里做三件事:校验时间戳在 ±5 分钟内(防重放)、按 App-Key 取出对应密钥重算 HMAC、比对签名。时间戳窗口这一步特别重要,没有它,攻击者抓到一次合法请求就能无限重放。校验通过返回 200,失败返回 401——Nginx 会自动把 401 透传给客户端,后端根本不会被调用。
这套做法的好处是签名算法的密钥只存在于鉴权服务里,业务后端完全不需要知道密钥,密钥泄露面大幅收窄。对个人站长做开放接口来说,这是成本极低的加固手段。
十、什么时候该用,什么时候别用
auth_request 最适合的场景是:多个异构后端共用一套鉴权、或者想让鉴权逻辑与业务代码解耦。比如你同时有 PHP 主站、Python 数据接口、Go 微服务,让它们各自实现一遍会话校验就是重复劳动,统一交给 Nginx 前置的鉴权服务,干净利落。
但如果你的站只有一个 PHP 应用、会话本来就在应用里,硬套 auth_request 反而多一跳,得不偿失。技术选型永远看场景,不要因为「架构好看」就给一个博客系统上微服务式的鉴权。