oauth2-proxy 前向认证:为任意应用接入 SSO
使用 oauth2-proxy 和 OIDC 为无登录功能的应用接入 SSO,配置 Nginx 或 Traefik 前向认证,并避开 Cookie、重定向及直接暴露端口的常见陷阱。
使用前向认证:让没有登录功能的应用接入 SSO
oauth2-proxy 可以为自身没有登录功能的应用提供单点登录。其工作原理是,应用前面的反向代理拦截每个请求,询问 oauth2-proxy 该请求是否携带有效会话。只有得到肯定答复后,代理才会将请求转发到上游。应用代码无需修改,因为应用根本看不到这次检查。
这次检查会额外发送一个 HTTP 请求。代理将传入请求的请求头副本发送到 /oauth2/auth,然后读取状态码。202 表示调用方已有会话,因此代理会将原始请求转发到应用。401 表示没有会话,因此代理会将浏览器重定向到 /oauth2/sign_in,由该地址在身份提供商处发起 OpenID Connect(OIDC)登录。OIDC 是构建在 OAuth 2.0 之上的身份层,身份提供商可以是您现有的登录系统。
每种反向代理都为这种模式提供了专用名称。Nginx 将其称为指令 auth_request。Traefik 将其称为中间件 forwardAuth。Caddy 将其写作 forward_auth。处理子请求的服务同样可以替换。oauth2-proxy 是常见选择,因为它直接支持 OIDC,且不需要自带数据库。
在编写任何配置前,先明确信任边界
检查成功后,oauth2-proxy 会通过响应标头返回身份信息,反向代理再将这些标头复制到发往上游的请求中。启用 set_xauthrequest 后,您会获得 X-Auth-Request-User 和 X-Auth-Request-Email。应用读取这些标头,并信任其中的信息。
这就是完整的安全模型,因此必须明确其后果。任何能够与应用端口建立 TCP 连接的对象,都可以自行设置这些标头并冒充任意用户。只要单个 curl -H "X-Auth-Request-Email: admin@example.com" http://app-host:3000/ 能够直接访问应用,就可以完全绕过认证。
因此,应用不能通过代理以外的路径访问。在 Docker Compose 中,从应用服务中删除 ports: 映射,并让应用保留在内部网络中,这样只有代理容器可以连接它。在裸机主机上,将应用绑定到 127.0.0.1:3000,而不是 0.0.0.0:3000。然后检查实际暴露的内容:
sudo ss -tlnp | grep 3000如果某一行显示 0.0.0.0:3000,则表示应用正在公网 IP 上响应,您的认证入口形同虚设。127.0.0.1:3000 才是预期结果。防火墙规则可以作为第二层防护,但绑定地址在其他工具刷新规则集后仍然有效。
安装 oauth2-proxy
截至 2026 年 8 月,当前版本为 v7.15.3,于 2026 年 6 月发布。安装二进制文件并验证下载内容:
cd /tmp
curl -fsSLO https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.15.3/oauth2-proxy-v7.15.3.linux-amd64.tar.gz
curl -fsSLO https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.15.3/oauth2-proxy-v7.15.3.linux-amd64.tar.gz-sha256sum.txt
sha256sum -c oauth2-proxy-v7.15.3.linux-amd64.tar.gz-sha256sum.txt
tar -xzf oauth2-proxy-v7.15.3.linux-amd64.tar.gz
sudo install -m 755 oauth2-proxy-v7.15.3.linux-amd64/oauth2-proxy /usr/local/bin/oauth2-proxy
oauth2-proxy --versionsha256sum -c 必须输出以 OK 结尾的行。如果输出 FAILED,请停止操作并重新下载,不要运行该二进制文件。
在 Docker 中,镜像为 quay.io/oauth2-proxy/oauth2-proxy,应固定标签:quay.io/oauth2-proxy/oauth2-proxy:v7.15.3。将其保留为 latest 会使例行的 docker compose pull 变成一次非计划升级,影响这台服务器上保护所有应用的唯一进程。
生成 Cookie 密钥
会话 Cookie 经过加密,cookie_secret 是加密密钥。它的长度必须正好是 16、24 或 32 字节,因为它会作为 AES(高级加密标准)密钥使用。长度不符合要求时,oauth2-proxy 将拒绝启动,并在启动错误中指出 Cookie 密钥存在问题。
openssl rand -base64 32 | tr -- '+/' '-_'tr 并非仅用于格式转换。它会将标准 base64 转换为 URL 安全字符集,使该值在 shell、环境变量文件和 HTTP 标头中传递时无需处理引号问题。
此值需要遵循两条规则。每个部署都应使用不同的密钥。如果在同一个域名后运行多个 oauth2-proxy 实例,则所有实例必须使用相同的密钥,因为一个实例加密的 Cookie 必须能被其他实例读取。
编写 oauth2-proxy 配置
将设置保存到文件中,而不是写成长命令行。这样客户端密钥不会出现在 ps 输出中。
# /etc/oauth2-proxy/oauth2-proxy.cfg
http_address = "127.0.0.1:4180"
reverse_proxy = true
provider = "oidc"
oidc_issuer_url = "https://id.example.com/application/o/myapp/"
client_id = "REPLACE_ME"
client_secret = "REPLACE_ME"
redirect_url = "https://app.example.com/oauth2/callback"
cookie_secret = "REPLACE_ME"
cookie_secure = true
cookie_domains = [".example.com"]
whitelist_domains = [".example.com"]
email_domains = ["*"]
set_xauthrequest = true
upstreams = ["static://202"]reverse_proxy = true 告诉 oauth2-proxy 信任前置代理发送的 X-Forwarded-* 标头。否则,oauth2-proxy 会将代理自身的地址视为客户端地址,并可能错误判断请求是否通过 HTTPS 到达。
upstreams = ["static://202"] 让 oauth2-proxy 对已认证请求返回 202,不执行代理操作。这正是 forward auth 所需的行为,因为反向代理负责转发请求。另一种部署方式是使用 upstreams = ["http://127.0.0.1:3000"],让 oauth2-proxy 直接处于请求路径中,完全不使用 auth_request。这种方式适合单个应用,但不适合扩展到十个应用。
email_domains = ["*"] 会接受提供商认证的所有地址。应将其限制为自己的域名。更好的做法是在提供商中通过组绑定限制访问,因为人员管理已经集中在提供商中。
使用独立用户通过 systemd 运行:
# /etc/systemd/system/oauth2-proxy.service
[Unit]
Description=oauth2-proxy
After=network-online.target
Wants=network-online.target
[Service]
User=oauth2-proxy
Group=oauth2-proxy
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/oauth2-proxy.cfg
Restart=on-failure
ProtectSystem=strict
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.targetsudo useradd --system --no-create-home --shell /usr/sbin/nologin oauth2-proxy
sudo install -d -m 750 /etc/oauth2-proxy
sudo chown -R oauth2-proxy:oauth2-proxy /etc/oauth2-proxy
sudo chmod 600 /etc/oauth2-proxy/oauth2-proxy.cfg
sudo systemctl daemon-reload
sudo systemctl enable --now oauth2-proxy
curl -s http://127.0.0.1:4180/ping/ping 输出 OK 表示进程已启动并加载了配置。这是 oauth2-proxy 自带的健康检查端点,不会要求会话。如果没有响应,请查看 journalctl -u oauth2-proxy -n 50。错误的 issuer URL 和长度不正确的 cookie secret 都会导致启动失败,日志会明确说明原因。
向提供商注册重定向 URI
在提供商中创建 OIDC 应用,并将其重定向 URI 设置为配置中的 redirect_url:https://app.example.com/oauth2/callback。两者必须完全一致,协议、主机、端口和路径都必须逐字符匹配。末尾的斜杠会使其成为不同的 URI。
这是整个配置中最常见的故障,而且在 oauth2-proxy 介入之前就已发生。提供商会拒绝授权请求并显示自己的错误页面,因此 oauth2-proxy 日志中不会出现任何内容。判断依据是地址栏:浏览器仍停留在提供商的域名下,查询字符串中包含 error=invalid_request,或页面直接显示 redirect_uri。出现这种情况时,应修复提供商中的应用记录,而不是代理配置。
请直接从提供商复制 issuer URL,不要手动输入。oauth2-proxy 会将 /.well-known/openid-configuration 拼接到 oidc_issuer_url,并在启动时获取该发现文档。先手动检查:
curl -s https://id.example.com/application/o/myapp/.well-known/openid-configuration | head -c 400包含 authorization_endpoint 键的 JSON 表示 issuer URL 正确。404 或 HTML 错误页面表示 URL 错误,oauth2-proxy 也会因同一个 404 错误而启动失败。如果尚未选择提供商,请参阅Keycloak、Authentik 和 Zitadel 的比较了解各项权衡;将 Authentik 作为自己的 SSO 服务器运行则介绍此配置中提供商部分的完整流程。
Nginx:auth_request
Nginx 使用 auth_request 执行转发认证。该指令会发起内部子请求,并根据其状态码进行分支处理。
# in the http context, next to your other maps
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name app.example.com;
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Auth-Request-Redirect $request_uri;
}
location = /oauth2/auth {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
location / {
auth_request /oauth2/auth;
error_page 401 = @oauth2_signin;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-User $user;
proxy_set_header X-Email $email;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location @oauth2_signin {
return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
}
}其中有 3 个细节值得保留。使用空的 Content-Length 配置 proxy_pass_request_body off 后,nginx 不会将每个 POST 请求的请求体复制到子请求中。这一点很重要,因为 oauth2-proxy 不会读取请求体。对于文件上传,默认配置会将文件发送两次。
auth_request_set $auth_cookie 和 add_header Set-Cookie 配合使用,可将刷新的会话 Cookie 返回给浏览器。如果省略它们,cookie_refresh 会静默失效,因为 nginx 会丢弃子请求的 Set-Cookie,浏览器会继续使用旧值,直到会话过期。
error_page 401 = @oauth2_signin 会将失败的检查转换为登录请求。没有它,未通过身份验证的访问者只会看到空白的 401 Authorization Required 页面,无法继续操作。
始终先测试,再重新加载:
sudo nginx -t && sudo systemctl reload nginx如果您不熟悉周围的指令,请参阅nginx 反向代理配置剖析,了解这一层配置的基础。
Traefik:forwardAuth 中间件
Traefik 需要两个中间件来完成同一项工作。一个负责执行检查。另一个负责将 401 转换为浏览器重定向。
# dynamic configuration
http:
middlewares:
oauth-auth:
forwardAuth:
address: https://oauth.example.com/oauth2/auth
trustForwardHeader: true
oauth-errors:
errors:
status:
- "401-403"
service: oauth-backend
query: "/oauth2/sign_in?rd={url}"
statusRewrites:
"401": 302将两个中间件都附加到面向应用的路由器,并在 oauth.example.com 上为 oauth2-proxy 单独发布一个路由器,因为浏览器必须能够访问 /oauth2/sign_in 和 /oauth2/callback,且不能经过该检查。
statusRewrites 将 401 映射为 302 是最容易遗漏的部分。没有此配置时,Traefik 会以 401 状态返回登录重定向,浏览器不会跟随该重定向,访问者会看到一个只包含单词 Found. 的页面。
trustForwardHeader: true 会将原始主机名和 URI 传递给 oauth2-proxy。oauth2-proxy 需要使用这些信息生成 rd 值,以便在用户登录后将其返回到请求的页面。将 whitelist_domains 设置为同时覆盖该主机名,否则 oauth2-proxy 会出于开放重定向风险考虑丢弃 rd 参数,所有用户登录后都会到达 /。Traefik 服务器通常会同时为多个应用提供入口,通过一个 Traefik 实例路由多个 Docker Compose 应用介绍了此配置所接入的路由器布局。
Caddy:forward_auth
app.example.com {
handle /oauth2/* {
reverse_proxy oauth2-proxy.internal:4180 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Uri {uri}
}
}
handle {
forward_auth oauth2-proxy.internal:4180 {
uri /oauth2/auth
header_up X-Real-IP {remote_host}
copy_headers X-Auth-Request-User X-Auth-Request-Email
@error status 401
handle_response @error {
redir * /oauth2/sign_in?rd={scheme}://{host}{uri}
}
}
reverse_proxy upstream.internal:3000
}
}这里顺序很重要。/oauth2/* 块必须放在前面,并且不包含 forward_auth,因为未登录的访问者必须能够访问登录和回调路径。如果将检查放在这些路径之前,登录请求会不断重定向到自身,直到浏览器放弃。
copy_headers 会将身份信息传递到上游请求中,但只有在 oauth2-proxy 使用 set_xauthrequest = true 运行时才会生成这些值。如何选择代理需要单独讨论,nginx、Caddy 和 Traefik 的比较对此进行了说明。
登录后为什么又回到了登录页面?
您在身份提供商处完成登录,身份提供商将您重定向回来,但 oauth2-proxy 又立即将您重定向到身份提供商。出现循环,说明回调请求到达时,没有携带 oauth2-proxy 在重定向前设置的 cookie。日志会明确指出具体情况:
No cookies were found in OAuth callback.或者,某个其他 cookie 已成功发送,但正确的 cookie 没有发送:
Cookies were found in OAuth callback, but none was a CSRF cookie.CSRF 是跨站请求伪造。此 cookie 用于将回调请求与发起该请求的登录流程关联起来。浏览器显示的错误通常是:
Login Failed: Unable to find a valid CSRF token. Please try again.请按以下顺序检查这 4 个原因。
cookie_secure = true,但浏览器通过普通 HTTP 访问站点。浏览器不会在http://origin 上存储标记为Secure的 cookie,因此也不会将其发回。请正确终止 TLS(传输层安全),或者仅在 localhost 上测试时设置cookie_secure = false。cookie_domains值未覆盖地址栏中的主机名。.example.com会覆盖app.example.com,对app.example.net完全不起作用。- 浏览器丢弃了 cookie。严格的隐私扩展或第三方 cookie 阻止功能,可能会在外发重定向与回调之间删除
_oauth2_proxy_csrf。 - 时钟漂移。如果服务器时钟与身份提供商的时钟相差太大,ID token 的
iat和exp会超出接受窗口,导致会话在到达时被拒绝。timedatectl应报告System clock synchronized: yes。
请从服务器端观察实际过程,不要凭猜测判断:
sudo journalctl -u oauth2-proxy -f在隐私窗口中打开应用。每个请求都会记录其状态码,因此回调后立即再次重定向到身份提供商,就能在日志中确认这是登录循环。
必须跳过登录的路径:API、Webhook 和 WebSocket
转发认证假定浏览器持有 Cookie。没有浏览器的调用方会失败。
发送 Authorization: Bearer <token> 的 API 客户端没有 Cookie,因此会收到指向身份提供商登录页的 302 响应,随后尝试将 HTML 解析为 JSON。有两种直接的解决方法。设置 skip_jwt_bearer_tokens = true 后,oauth2-proxy 会接受由同一身份提供商签发的有效 JWT(JSON Web Token)Bearer 令牌。API 客户端已经从该身份提供商获取令牌时,应使用此方法。否则,排除该路径:
skip_auth_routes = [
"^/api/",
"POST=^/webhook/",
"GET=^/healthz$"
]每个值都是针对规范化路径进行匹配的正则表达式,也可以在前面添加 HTTP 方法和 =。POST=^/webhook/ 会允许 Webhook 接收器处理 POST 请求,而用户在浏览器中访问同一路径时仍会进入登录流程。每一条配置都是认证网关中的一个缺口,因此请使用 ^ 锚定表达式,并将范围限制在调用方所需的最小范围内。
WebSocket 是最容易配置错误的场景。升级请求是普通的 HTTP GET 请求,携带的 Cookie 与其他请求相同,因此通常可以通过检查,无需排除该路径。真正出问题的是相关的代理配置。如果受保护的位置缺少 Upgrade 和 Connection 请求头,升级就无法完成,应用客户端会不断重试,浏览器控制台中会显示 WebSocket connection ... failed 消息。排除该路径无法解决问题,因为该请求此前已经通过授权检查。
但有一个实际限制。检查只在升级时执行一次。保持数小时打开的 WebSocket 不会再次进行检查,因此在身份提供商中移除用户不会关闭该用户已经建立的连接。重启应用即可断开现有连接。
会话存储,以及过大的 Cookie
默认情况下,整个会话都存储在 Cookie 中,并使用您的 cookie_secret 加密。这样 oauth2-proxy 可保持无状态,也不需要额外服务。但 Cookie 的大小有限,因为浏览器通常将 Cookie 限制在约 4 KB。ID token 携带较长的群组声明列表时,oauth2-proxy 会将会话拆分到 _oauth2_proxy_0、_oauth2_proxy_1 等 Cookie 中。拆分出几个部分后,请求头可能变得过大,导致 nginx 在应用收到请求前返回 400 Request Header Or Cookie Too Large。
出现这种情况时,将会话移到服务器端:
session_store_type = "redis"
redis_connection_url = "redis://127.0.0.1:6379"此时,浏览器只保存一个较短的票据,加密后的会话存储在 Redis 中。代价是必须持续运行一个额外服务:如果 Redis 宕机,所有会话都会失效,所有用户会同时退出登录。Cookie 存储也有其代价:两个请求同时刷新同一会话时可能发生冲突,并强制用户重新登录。
正向认证无法提供什么
这只是入口处的一道门禁。它不是应用内部的授权机制,这一区别决定了该方案是否适合您的场景。
用户通过门禁后,应用看到的内容与之前完全相同。如果应用有自己的角色体系,正向认证不会自动填充这些角色,除非应用支持基于请求头的身份验证,并能将某个请求头映射到用户账户。Grafana 通过其 auth.proxy 设置支持这一点。大多数自托管应用不支持,因此所有通过门禁的用户在应用看来都是同一个身份,而该身份通常还是管理员。
它也不会保护应用自身的 API 令牌。应用签发的个人访问令牌用于向应用进行身份验证,而不是向 oauth2-proxy 进行身份验证。因此,只要在 API 前面加上门禁,该令牌就会立即失效。若要恢复令牌访问,您必须豁免 API 路径;这样一来,该令牌就成了保护该路径的唯一机制。现在,同一服务上运行着两套身份验证系统,而 SSO 只覆盖其中一套。
撤销是第三个缺口。在身份提供商中删除用户后,新的登录会被阻止,cookie_refresh 执行的令牌刷新也会停止,但现有会话 Cookie 仍会保持有效,直到过期。cookie_expire 的默认值为 168 小时,也就是被删除的用户仍可访问一周。将 cookie_refresh 设置为较短的时间,例如 1 小时,让撤销操作在这个时间窗口内生效。
审计记录也只到门禁为止。oauth2-proxy 会记录谁在何时通过了门禁。应用记录的则是一个未命名的会话。如果您需要回答是谁修改了某项设置,那么在应用中记录请求头身份是最低要求,而真正为每个用户创建独立账户才是可靠的做法。
支付 SSO 费用更合适的情况
如果应用完全没有登录功能,或只有一个共享密码,而您希望在一个位置统一添加和移除用户,前置身份验证就是合适的工具。它只需一个下午和一个额外进程,并且适用于任何支持 HTTP 的应用。
如果同一应用中的不同用户需要不同权限,前置身份验证就不合适。入口无法表达“Ana 可以编辑仪表板,而 Bo 只能查看”。如果供应商提供 SSO 版本,您实际购买的通常是用户组到角色的映射;使用请求头和代理规则重新实现这套功能,比直接付费更容易出现问题。决定之前,建议阅读SSO 版本背后的定价模式。
另外两种情况也指向同一个结论。需要在应用内部生成逐用户审计记录的合规工作,不会接受代理访问日志作为证据。任何带有不保留浏览器 Cookie 的移动端或桌面客户端的应用,也会在每次请求时与这个入口发生冲突。
FAQ
什么是 forward auth?
forward auth 是一种模式:反向代理在将每个传入请求转发到上游之前,先向独立的身份验证服务查询。代理将请求标头发送到类似 /oauth2/auth 的端点,并读取状态码。202 表示允许,因此原始请求会继续转发到应用。401 表示没有会话,因此代理会将浏览器重定向到登录页面。Nginx 使用 auth_request 指令实现,Traefik 使用 forwardAuth 中间件实现,Caddy 使用 forward_auth 实现。
为什么 oauth2-proxy 会循环将我重定向回登录页面?
回调请求到达 oauth2-proxy 时没有携带 CSRF cookie,因此 oauth2-proxy 会重新开始认证流程。服务器日志显示 No cookies were found in OAuth callback.,浏览器显示 Login Failed: Unable to find a valid CSRF token. Please try again.。最常见的原因是:网站通过普通 HTTP 提供访问,但配置了 cookie_secure = true;浏览器不会在 http:// origin 上存储 Secure cookie。另一个常见原因是 cookie_domains 值未覆盖地址栏中的主机名。
如何让 API 客户端或 webhook 通过 oauth2-proxy?
使用 skip_auth_routes 配合带锚点的正则表达式,也可以限制为某个 HTTP 方法,例如 POST=^/webhook/。如果 API 客户端已经持有同一提供商签发的 JWT,skip_jwt_bearer_tokens = true 可以接受这些令牌来代替 cookie,同时保持路径受保护。列在 skip_auth_routes 中的任何内容都对所有人免认证,因此每个表达式都应尽量限制在调用方实际需要的范围内。
oauth2-proxy 会为应用提供按用户划分的权限吗?
不会。它是一个访问闸门,不是授权系统。它只决定谁可以访问应用;除非应用读取身份标头并将其映射到用户账户,否则所有通过闸门的用户在应用看来都完全相同。Grafana 可以通过其 auth.proxy 设置实现这一点。大多数自托管应用无法实现,因此所有通过闸门的用户都会共享应用运行时使用的同一个身份。
用户可以自行设置身份标头来绕过 oauth2-proxy 吗?
可以,前提是他们能够直接访问应用。身份信息以普通标头的形式传递,例如 X-Auth-Request-Email,应用会信任收到的内容。任何能够连接应用端口的用户都可以发送该标头,并冒充任意用户。将应用绑定到 127.0.0.1,或将其保留在没有发布端口的内部 Docker 网络中,然后使用 sudo ss -tlnp 进行确认。