一个请求打到/admin/api/stats,中间件发现没有会话,顺手一个302甩到/login。浏览器乖乖跳转,用户看到登录页,开发者觉得问题解决了。直到某天,一个API客户端拿到了一整页HTML登录表单,而不是它期待的JSON错误。

这不是偶发bug,是大多数Next.js认证失败案例的共同根源:把HTTP 302重定向当成了通用错误响应。

打开网易新闻 查看精彩图片

重定向在说什么,401和403在说什么

语义上的错位才是关键。重定向告诉客户端"你要的资源在别处";401 Unauthorized说的是"你需要凭证";403 Forbidden说的是"你的凭证没有访问这个资源的权限"。三句话,三个完全不同的意思。

当中间件把一个API请求重定向到登录页,客户端收到的是302状态码,指向一个HTML表单,而不是机器可读的错误。当搜索引擎爬虫撞上受保护路由被弹到登录页,它会把登录页URL当作那份受保护内容的规范地址收录进去。当一个已登录用户没有/admin/settings的权限,被重定向到/dashboard,认证失败和授权失败的区别就被彻底抹掉了。

Next.js 15给出的答案:让错误变成状态码

Next.js 15引入了forbidden()和unauthorized()两个函数,它们位于next/navigation,会抛出错误,由App Router在服务端渲染期间拦截并转换成对应的HTTP响应:forbidden() 对应403,unauthorized() 对应401。

和重定向不同,这些响应保留原始请求URL不变,同时把访问失败的原因说清楚。这一点对浏览器的凭证管理器、HTTP缓存和机器可读的错误处理都很重要。

看一段典型的服务端组件写法:

  • next/navigation引入 unauthorized 和 forbidden
  • 调用 getSession() 获取会话
  • 没有会话 → 调用 unauthorized()
  • 有会话但角色不是admin → 调用 forbidden()

渲染时,认证检查发生在任何JSX返回之前。getSession() 找不到有效会话,unauthorized() 抛错,渲染中断。App Router接住这个错误,向浏览器返回HTTP 401,浏览器可以触发内置的凭证提示或OAuth流程。如果会话存在但用户没有管理员权限,forbidden() 抛错,产生HTTP 403,告诉浏览器"你已认证,但这个资源不归你"。

URL不变,意图就还在

浏览器拿到带正确状态码的响应,可以做出恰当处理。请求URL保持在/admin,不会变成/login,书签和历史记录保留了用户的原始意图。解析响应的API客户端看到的是401或403状态码,而不是从登录页返回的HTML。搜索引擎爬虫明白这份内容需要认证,不会把它当公开内容收录。

这套模式适用于任何服务端组件、Server Action或Route Handler。关键要求是代码运行在请求生命周期内的服务端,而不是客户端水合或状态更新阶段。App Router的错误边界系统会处理抛出的错误,在向客户端发送任何内容之前转换成合适的HTTP响应。

对照一下旧的中间件写法:拦截/admin/*的每个请求,检查会话cookie,缺失就重定向到/login。浏览器跟随跳转,地址栏改变,用户看到登录页。问题在爬虫抓取/admin/users被弹到登录页时浮现,在API客户端请求/admin/api/stats收到HTML登录页而不是401 JSON时浮现。

语义化方案把认证检查搬进服务端组件,返回正确的HTTP状态码。请求打到/admin/users而没有凭证时,返回的不再是一个跳转,而是一个明确的态度。