1. 从一次 401 说起:Next.js 中间件里鉴权与限流到底卡在哪
如果你正在用 TypeScript + React + Next.js + MongoDB + Docker 这套组合做全栈项目,大概率会在某个阶段遇到这样的场景:前端 React 页面发请求,Next.js 的 API 路由或中间件需要先判断用户有没有登录,再决定要不要放行;同时你还想给接口加一层限流,防止某个用户疯狂刷接口。听起来是两个独立功能,但真正写起来,鉴权和限流的执行顺序、Key 的统一管理、MongoDB 会话校验的异步等待,很容易把链路搞乱。
我自己在本地用 Docker 起 MongoDB 和 Next.js 服务时,就踩过一个典型的坑:中间件里先查了 MongoDB 会话,发现没登录直接返回 401,但限流逻辑写在后面,结果未登录请求根本没被计数,攻击者可以无限次尝试登录接口。后来把顺序调成「先限流、再鉴权」,又发现限流用的 Key 如果每个请求都重新生成,根本起不到限制作用。问题的核心在于:鉴权和限流都需要一个稳定、可复用的标识,而这个标识最好由统一的 Key 通道来提供。
这一章要解决的就是这件事。我会用 TypeScript 写一个可复用的 middleware 模块,把 React 前端请求、Next.js 中间件、MongoDB 会话校验串起来,并且用 Docker 在本地起服务验证。中间会接入 TaoToken 的统一 Key 和 API 通道,让鉴权和限流共用同一套凭证来源,避免 Key 散落在各个文件里。你跟着做,最后能用 curl 分别触发 401 和 429,看到完整的链路跑通。
适合谁看:已经写过 Next.js API 路由、对 MongoDB 有基本了解、想搞清楚中间件里鉴权限流怎么配合的开发者。不需要你之前用过 TaoToken,但需要你本地有 Docker 和 Node.js 环境。
核心检索词先明确:Next.js 中间件鉴权限流、TypeScript 可复用 middleware、MongoDB 会话校验、TaoToken 统一 Key、Docker 本地验证。这几个词会贯穿全文,你可以在每一步里找到对应的落点。
先说结论:鉴权和限流不是二选一,而是有明确顺序的管道。请求进来,先过限流(基于 Key 计数),再过鉴权(基于 MongoDB 会话),最后才到业务逻辑。Key 从哪来?从 TaoToken 的统一通道拿,这样前端、中间件、后端服务用的是同一套凭证,不会出现「前端有 Key、中间件不认」的割裂。
下面从环境准备开始,一步步把这条链路搭起来。
2. TaoToken 统一 Key 前置准备:把凭证收口到一处
在写中间件之前,先把 Key 的来源理清楚。很多项目的问题不是代码写错,而是 Key 管理混乱:前端环境变量里一个、Next.js 服务端一个、MongoDB 连接串里又嵌一个,最后排查 401 时根本不知道是哪个环节的 Key 失效了。TaoToken 在这里的作用是提供一个统一的 API 通道和 Key 管理入口,让鉴权和限流都从同一个地方取凭证。
你需要先拿到一个可用的 Key。打开 TaoToken 官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册登录后进入控制台。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在里面找到 API Keys 页面,路径是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。新建一个 Key,复制出来,后面会写进.env.local。
这里要强调一点:TaoToken 的 API 基础地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,配置的时候直接用这个。Key 的格式通常是一串以sk-开头的字符串,具体以你控制台看到的为准。
为什么要在中间件项目里用 TaoToken 的 Key?因为鉴权和限流都需要一个「请求身份」的锚点。限流按 Key 计数,鉴权用 Key 关联的会话去 MongoDB 查用户状态。如果 Key 不统一,限流计的是 A Key,鉴权查的是 B 会话,两边对不上,就会出现「明明登录了却被限流」或者「没登录却绕过了限流」的怪现象。
接下来在项目根目录创建环境变量文件。Next.js 默认读取.env.local,这个文件不要提交到 Git。内容如下:
# .env.local TAOTOKEN_API_BASE=https://taotoken.net/api TAOTOKEN_API_KEY=sk-你的实际Key MONGODB_URI=mongodb://localhost:27017/fullstack_demo NEXT_PUBLIC_APP_URL=http://localhost:3000注意TAOTOKEN_API_KEY没有NEXT_PUBLIC_前缀,这意味着它只在服务端可用,不会被打包进浏览器代码。这是安全底线:Key 不能暴露给前端。前端 React 组件如果需要调用受保护接口,走的是同源请求,由 Next.js 中间件在服务端完成 Key 注入和校验。
如果你用的是 Claude Code 或者类似的编码工具来辅助开发,可以在 TaoToken 的 Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 了解长期编码场景的配置方式。不过本章的重点是中间件本身,Key 拿到手就够了。
还有一个细节:TaoToken 的模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你在配置过程中对 Base URL 或 Key 的用法有疑问,这两个页面能帮你确认参数格式。我实测下来,文档里的 Base URL 和 Key 组合是最省事的,不用自己猜。
环境变量准备好之后,先别急着写中间件。下一步是把 MongoDB 用 Docker 跑起来,并且确认 Next.js 能连上。因为鉴权依赖 MongoDB 里的会话数据,如果数据库没起来,中间件里的会话校验会直接抛错,你看到的可能不是 401 而是 500,排查起来会绕远路。
Docker 起 MongoDB 的命令很简单,但要注意端口映射和数据卷。我用的是下面这个:
docker run -d \ --name fullstack-mongo \ -p 27017:27017 \ -v mongo-data:/data/db \ -e MONGO_INITDB_DATABASE=fullstack_demo \ mongo:7跑起来之后用docker ps确认容器状态是 Up。然后可以进容器里用 mongosh 建一个测试集合,或者直接在 Next.js 里用 Mongoose 连接。这里先不展开 Mongoose 模型,下一节写中间件配置时会带上。
Key 和数据库都就位后,就可以进入核心部分:写可复用的 TypeScript 中间件模块。
3. 可复制配置:TypeScript 中间件模块与 settings 片段
这一节给出可以直接复制到项目里的配置和代码。目标是在 Next.js 的middleware.ts里实现两个能力:基于 TaoToken Key 的限流,以及基于 MongoDB 会话的鉴权。为了让代码可复用,我把限流和鉴权拆成独立函数,放在lib/middleware/目录下。
先看目录结构,这样你知道每个文件放哪:
project-root/ ├── middleware.ts ├── lib/ │ └── middleware/ │ ├── rate-limit.ts │ ├── auth.ts │ └── key.ts ├── models/ │ └── session.ts ├── .env.local └── package.jsonmiddleware.ts是 Next.js 的入口,lib/middleware/下是具体逻辑,models/session.ts是 MongoDB 会话模型。先写 Key 解析模块lib/middleware/key.ts:
// lib/middleware/key.ts export function resolveApiKey(req: Request): string | null { const headerKey = req.headers.get("x-api-key"); if (headerKey && headerKey.startsWith("sk-")) { return headerKey; } const auth = req.headers.get("authorization"); if (auth && auth.startsWith("Bearer sk-")) { return auth.slice(7); } return null; } export function getServerKey(): string { const key = process.env.TAOTOKEN_API_KEY; if (!key) { throw new Error("TAOTOKEN_API_KEY is not set"); } return key; }这个模块做两件事:从请求头里解析客户端带来的 Key,以及从服务端环境变量读取统一 Key。限流用客户端 Key 做计数维度,鉴权时如果客户端没带 Key,可以回退到服务端 Key 去 TaoToken 校验会话有效性。
接着写限流模块lib/middleware/rate-limit.ts。这里用一个内存 Map 做演示,生产环境应该换成 Redis,但本地验证足够:
// lib/middleware/rate-limit.ts type Bucket = { count: number; resetAt: number }; const buckets = new Map<string, Bucket>(); const WINDOW_MS = 60_000; const MAX_REQUESTS = 5; export function checkRateLimit(key: string): { allowed: boolean; remaining: number; resetAt: number; } { const now = Date.now(); const bucket = buckets.get(key); if (!bucket || now > bucket.resetAt) { const resetAt = now + WINDOW_MS; buckets.set(key, { count: 1, resetAt }); return { allowed: true, remaining: MAX_REQUESTS - 1, resetAt }; } if (bucket.count >= MAX_REQUESTS) { return { allowed: false, remaining: 0, resetAt: bucket.resetAt }; } bucket.count += 1; return { allowed: true, remaining: MAX_REQUESTS - bucket.count, resetAt: bucket.resetAt, }; }参数说明:WINDOW_MS是时间窗口,MAX_REQUESTS是窗口内最大请求数。我设成 5 次每分钟,方便你用 curl 快速触发 429。buckets以 Key 为维度存储计数,同一个 Key 的请求共享计数。
然后是鉴权模块lib/middleware/auth.ts,它需要连 MongoDB 查会话:
// lib/middleware/auth.ts import mongoose from "mongoose"; import { SessionModel } from "@/models/session"; let connected = false; async function ensureDb() { if (connected) return; const uri = process.env.MONGODB_URI; if (!uri) throw new Error("MONGODB_URI is not set"); await mongoose.connect(uri); connected = true; } export async function verifySession(apiKey: string): Promise<{ valid: boolean; userId?: string; }> { await ensureDb(); const session = await SessionModel.findOne({ apiKey, expiresAt: { $gt: new Date() } }).lean(); if (!session) { return { valid: false }; } return { valid: true, userId: String(session.userId) }; }MongoDB 会话模型models/session.ts:
// models/session.ts import { Schema, model, models } from "mongoose"; const SessionSchema = new Schema({ apiKey: { type: String, required: true, index: true }, userId: { type: Schema.Types.ObjectId, required: true }, expiresAt: { type: Date, required: true }, }); export const SessionModel = models.Session || model("Session", SessionSchema);最后是入口middleware.ts,把限流和鉴权串起来:
// middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { resolveApiKey, getServerKey } from "@/lib/middleware/key"; import { checkRateLimit } from "@/lib/middleware/rate-limit"; import { verifySession } from "@/lib/middleware/auth"; export async function middleware(req: NextRequest) { const path = req.nextUrl.pathname; if (path.startsWith("/api/public")) { return NextResponse.next(); } const clientKey = resolveApiKey(req); const key = clientKey ?? getServerKey(); const rate = checkRateLimit(key); if (!rate.allowed) { return NextResponse.json( { error: "Too Many Requests", resetAt: rate.resetAt }, { status: 429, headers: { "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": String(rate.resetAt), }, } ); } const session = await verifySession(key); if (!session.valid) { return NextResponse.json( { error: "Unauthorized" }, { status: 401 } ); } const res = NextResponse.next(); res.headers.set("X-User-Id", session.userId ?? ""); res.headers.set("X-RateLimit-Remaining", String(rate.remaining)); return res; } export const config = { matcher: ["/api/:path*"], };这段配置的关键点:限流在鉴权之前执行,所以未登录请求也会被计数,避免登录接口被无限刷。Key 优先取客户端带来的,没有则用服务端统一 Key。鉴权通过后把userId写进响应头,后续 API 路由可以直接读。
如果你用的是 Claude Code 或者 Cline 这类工具,配置里需要写全三件套:Base URL 用https://taotoken.net/api,Key 用你控制台生成的,Model ID 按你实际调用的模型填。这三者缺一不可,否则会出现local proxy failed或者reading choices之类的报错。CC Switch 场景下也是同样的三件套逻辑,Base URL、Key、Model ID 要对齐。
配置写完后,还需要一个种子脚本来往 MongoDB 里插一条测试会话,否则鉴权永远返回 401。可以用下面这个脚本:
// scripts/seed-session.ts import mongoose from "mongoose"; import { SessionModel } from "../models/session"; async function main() { await mongoose.connect(process.env.MONGODB_URI!); await SessionModel.deleteMany({}); await SessionModel.create({ apiKey: process.env.TAOTOKEN_API_KEY, userId: new mongoose.Types.ObjectId(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }); console.log("session seeded"); await mongoose.disconnect(); } main();用npx tsx scripts/seed-session.ts跑一下,确保数据库里有一条有效会话。到这里,配置部分就齐了。下一节用 curl 验证 401 和 429。
4. 验证请求:用 curl 触发 401 与 429 看真实结果
配置写完了,不验证等于没写。这一节用 curl 分别触发 401 和 429,并且解释每个响应头代表什么。先确保 Next.js 开发服务在跑:
npm run dev服务默认在http://localhost:3000。先测一个正常请求,带上正确的 Key:
curl -i http://localhost:3000/api/protected \ -H "x-api-key: sk-你的实际Key"预期返回 200,响应头里能看到X-User-Id和X-RateLimit-Remaining。X-RateLimit-Remaining会随着请求次数递减,从 4 开始往下走。如果你看到的是 401,说明 MongoDB 里没有匹配的会话,回去检查种子脚本是否跑成功,以及apiKey字段是否和请求头里的 Key 完全一致。
现在测 401。故意带一个不存在的 Key:
curl -i http://localhost:3000/api/protected \ -H "x-api-key: sk-invalid-key-for-test"预期返回:
HTTP/1.1 401 Unauthorized content-type: application/json {"error":"Unauthorized"}注意这里限流是先执行的,所以这个无效 Key 也会被计数。如果你连续发 5 次以上,会先看到 429 而不是 401。这正好验证了「限流在鉴权之前」的设计。
测 429 的时候,用一个有效 Key 连续发 6 次请求:
for i in $(seq 1 6); do echo "--- request $i ---" curl -s -o /dev/null -w "%{http_code}\n" \ http://localhost:3000/api/protected \ -H "x-api-key: sk-你的实际Key" done预期输出前 5 次是 200,第 6 次是 429。如果你想看 429 的响应体,去掉-o /dev/null:
curl -i http://localhost:3000/api/protected \ -H "x-api-key: sk-你的实际Key"第 6 次会返回:
HTTP/1.1 429 Too Many Requests x-ratelimit-remaining: 0 x-ratelimit-reset: 1730000000000 {"error":"Too Many Requests","resetAt":1730000000000}x-ratelimit-reset是时间戳,等它过去之后计数会重置。你可以用date -d @1730000000转成可读时间,确认窗口是 60 秒。
这里有个实测细节:Next.js 的 middleware 在开发模式下每次热更新会重新加载模块,内存里的buckets会被清空。所以如果你改了代码,限流计数会归零,需要重新发请求。生产环境用 Redis 就不会有这个问题。
另外,React 前端发请求时,如果用的是fetch,默认不会带x-api-key头。你需要在请求里显式加上,或者让 Next.js 的 API 路由在服务端注入。我一般在前端封装一个apiFetch函数:
// lib/api-client.ts export async function apiFetch(path: string, init?: RequestInit) { const key = process.env.NEXT_PUBLIC_TAOTOKEN_KEY; return fetch(path, { ...init, headers: { ...init?.headers, ...(key ? { "x-api-key": key } : {}), }, }); }注意这里用了NEXT_PUBLIC_前缀,意味着 Key 会暴露给浏览器。这在本地验证可以,生产环境不建议。更安全的做法是前端只带会话 Cookie,由中间件在服务端换成 TaoToken Key。这个取舍你要根据实际场景决定。
验证通过后,你已经有了一条完整的链路:React 请求 → Next.js 中间件 → 限流计数 → MongoDB 会话校验 → 业务路由。下一节把常见的报错列出来,方便你对照排查。
5. 常见错排查:401、429、local proxy failed、reading choices 对照
这一节按真实报错来组织。你在本地跑这条链路时,大概率会遇到下面几类问题,我按错误信息分类,给出原因和修法。
第一类:401 Unauthorized。这是最常见的。可能原因有三个。一是 MongoDB 里没有会话记录,或者apiKey字段和请求头里的 Key 不一致。修法是重新跑种子脚本,并且用mongosh进数据库确认:
docker exec -it fullstack-mongo mongosh fullstack_demo db.sessions.find().pretty()看apiKey字段的值是否和你 curl 里带的一致。二是会话过期了,expiresAt小于当前时间。种子脚本里设的是 24 小时,一般不会过期,但如果你手动改过就要检查。三是MONGODB_URI没配对,中间件连不上数据库,verifySession抛错被吞掉后返回了valid: false。这种情况建议在auth.ts里把 catch 的错误打出来,不要静默返回。
第二类:429 Too Many Requests。这个通常不是 bug,而是限流生效了。但如果你觉得「我才发了一次就 429」,检查MAX_REQUESTS是不是被改成了 1,或者buckets的 Key 是不是每次请求都变了。Key 变化的原因可能是resolveApiKey没解析到请求头,回退到了getServerKey(),而服务端 Key 是固定的,所以所有请求共享计数。这其实是预期行为,但如果你想让每个客户端独立计数,就要确保请求头里带了 Key。
第三类:local proxy failed。这个报错通常出现在你用编码工具或者 API 客户端配置 TaoToken 的时候。原因是 Base URL 或 Key 写错了。检查三件套:Base URL 是不是https://taotoken.net/api,Key 是不是以sk-开头,Model ID 是不是你实际调用的模型。三者缺一,或者 Base URL 多写了路径,都会导致代理失败。我踩过的坑是把 Base URL 写成了带/v1的地址,结果一直连不上,去掉/v1就正常了。
第四类:reading choices。这个报错一般出现在调用模型接口后解析响应时。原因是返回结构和你预期的字段不一致,代码里去读choices但实际没有这个字段。排查方法是先把原始响应打印出来,看实际返回的 JSON 结构。如果是 TaoToken 的接口,确认你用的 Model ID 和文档里的一致。接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有各模型的参数说明,对照一下。
第五类:OAuth 相关报错。如果你在配置 Claude Code 或者类似工具时看到 OAuth 失败,检查是不是把 API Key 和 OAuth 流程混用了。TaoToken 的 Key 是直接放在请求头里的,不需要走 OAuth 授权码流程。Claude Code 的配置入口在 https://taotoken.net/claude-code?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite ,里面有具体的配置示例。Anthropic 兼容接口的说明在 https://taotoken.net/anthropic?utm_source=taotoken_aicg_blog_end&utm_content=anthropic&utm_campaign=rewrite ,如果你用的是 Anthropic 风格的调用,注意 Base URL 和请求头的差异。
第六类:Docker 容器起来了但连不上 MongoDB。检查端口映射是不是27017:27017,以及 Next.js 里的MONGODB_URI是不是mongodb://localhost:27017/fullstack_demo。如果你在 Docker 容器里跑 Next.js,localhost要换成容器名或者host.docker.internal。这个坑很隐蔽,因为容器内外的网络视图不一样。
第七类:中间件不生效。Next.js 的middleware.ts必须放在项目根目录,和pages或app同级。如果你放在src目录下,要确认 Next.js 版本是否支持。另外config.matcher要匹配到你的 API 路径,写错了中间件根本不会执行。可以在中间件里加一行console.log("middleware hit", path)确认。
把这几类问题对照一遍,基本能覆盖本地验证时 90% 的报错。剩下的就是具体业务逻辑的问题了。
6. 把 Key 通道固定下来:后续接入与长期编码的选择
链路跑通之后,下一步要考虑的是怎么把这套配置稳定下来。本地验证用的内存限流和手动种子会话,到了真实项目里需要替换成更可靠的方案。限流换成 Redis,会话换成真实的登录流程写入 MongoDB,Key 从 TaoToken 控制台统一管理。这样鉴权和限流就不会因为服务重启而丢失状态。
如果你后续要长期用这套组合做编码或者 Agent 开发,可以了解 TaoToken 的 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它针对长期编码场景有更合适的配置方式。模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,你可以直接在里面测试 Key 是否可用。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,需要轮换 Key 或者查看用量时从这里进。
回到中间件本身,我建议你把lib/middleware/下的三个模块保持独立,不要把所有逻辑塞进middleware.ts。这样限流策略变了只改rate-limit.ts,鉴权逻辑变了只改auth.ts,Key 解析规则变了只改key.ts。React 前端那边,封装一个统一的请求函数,把 Key 注入和错误处理收口,避免每个组件都写一遍 fetch。
最后留一个实用技巧:在middleware.ts里给响应头加上X-RateLimit-Remaining和X-RateLimit-Reset,前端可以根据这两个值做倒计时提示,用户体验会好很多。这个细节在本地验证时看不出差别,但上线后能减少很多「为什么突然请求失败了」的困惑。