site logo

Marico's space

Next.js 15 中的速率限制与 API 安全:我实际使用的模式

前端技术 2026-08-06 20:56:13 4

做过公开表单接口的应该都有过这种经历:接口跑得好好的,突然有一天数据库账单翻了几倍,查日志才发现有个客户端在不断重试,一秒钟请求了上千次。这种事情发生一次就够刻骨铭心了。

现在每个有公开接口的项目,我都会上这套防护方案。

1. 用 Upstash 做速率限制

Upstash 的 Redis 方案对 Next.js 很友好,专门为无服务器场景设计,每次请求不需要保持连接。

npm install @upstash/ratelimit @upstash/redis
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL as string, token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
}); export const ratelimit = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '60 s'), analytics: true,
});

slidingWindow(10, '60 s') 的意思是每个标识符 60 秒内最多 10 次请求。滑动窗口比固定窗口更合理,因为固定窗口在两个时间段交界处会被人钻空子,瞬间能拿到两倍的请求配额。

2. 应用到路由处理器

// app/api/contact/route.ts
import { ratelimit } from '@/lib/ratelimit';
import { headers } from 'next/headers'; export async function POST(request: Request) { const headersList = await headers(); const ip = headersList.get('x-forwarded-for') ?? 'unknown'; const { success, limit, remaining, reset } = await ratelimit.limit(ip); if (!success) { return Response.json( { error: 'Too many requests' }, { status: 429, headers: { 'X-RateLimit-Limit': limit.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': reset.toString(), }, } ); } const body = await request.json(); // handle the actual request return Response.json({ success: true });
}

即使请求成功也返回 rate limit 相关 header,这是个好习惯。守规矩的客户端能看到自己离限制还有多远,提前降速,而不是等到收到 429 才反应过来。

3. 对 Server Action 做速率限制

Server Action 不是标准 HTTP 端点,拿 IP 不像路由处理器那么直接,需要多一步。

// actions/contact.ts
'use server';
import { ratelimit } from '@/lib/ratelimit';
import { headers } from 'next/headers'; export async function submitContact(formData: FormData) { const headersList = await headers(); const ip = headersList.get('x-forwarded-for') ?? 'unknown'; const { success } = await ratelimit.limit(`contact_${ip}`); if (!success) { return { success: false, message: 'Too many attempts, try again shortly' }; } // proceed with validation and saving return { success: true, message: 'Message sent' };
}

在标识符前面加 contact_ 前缀很重要。当多个 Server Action 共用一个限速器时,这样可以让每个 action 的用量独立统计,不会出现一个 action 的流量吃掉另一个 action 的配额。

4. 按用户限速而不是只按 IP

未登录的公开接口(比如联系表单)用 IP 限速没问题。对于需要登录的操作,按用户 ID 限速更准确——因为同一网络下多个用户共享 IP,而一个用户也可以通过切换 IP 来绕过 IP 限速。

// actions/posts.ts
'use server';
import { ratelimit } from '@/lib/ratelimit';
import { getSession } from '@/lib/auth'; export async function createPost(formData: FormData) { const session = await getSession(); if (!session) throw new Error('Not authenticated'); const { success } = await ratelimit.limit(`create_post_${session.userId}`); if (!success) { return { success: false, message: 'Slow down, try again in a minute' }; } // proceed
}

5. 在数据进数据库之前先验证输入

速率限制控制的是调用频率,但不能保证数据格式。每一个路由处理器和 Server Action 都需要独立做校验,原理跟表单处理一样,只不过这里是在 API 边界层面应用。

// app/api/users/route.ts
import { z } from 'zod'; const CreateUserSchema = z.object({ name: z.string().min(2).max(50), email: z.string().email(),
}); export async function POST(request: Request) { const body = await request.json(); const parsed = CreateUserSchema.safeParse(body); if (!parsed.success) { return Response.json({ error: 'Invalid input' }, { status: 400 }); } // proceed with parsed.data, never the raw body
}

永远不要把原始请求体直接传给数据库操作。即使上了速率限制,如果接口接受任意形状的输入,仍然可能被畸形数据、超大 payload 或者本来就不该从外部设置的字段搞出问题。

6. 公开 API 路由的 CORS 配置

如果某个 API 只允许自己的前端调用,限制 CORS 能防止其他网站直接用访问者的已登录态从浏览器发起请求。

// app/api/data/route.ts
export async function GET(request: Request) { const origin = request.headers.get('origin'); const allowedOrigin = process.env.NEXT_PUBLIC_URL; const response = Response.json({ data: 'example' }); if (origin === allowedOrigin) { response.headers.set('Access-Control-Allow-Origin', allowedOrigin); } return response;
}

这个对基于 Cookie 的认证特别关键。没有 CORS 限制,恶意网站可以用已登录用户的 Cookie 发起请求,如果没有上面这个检查,你的接口会像请求来自自家前端一样正常响应。

7. 密钥绝不暴露给客户端

Next.js 特别容易犯这个错:任何带 NEXT_PUBLIC_ 前缀的环境变量都会打包进客户端 JavaScript,在浏览器控制台里一目了然。

// ❌ This leaks the secret into the browser bundle
const apiKey = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY; // ✅ Secret keys stay server-only, no NEXT_PUBLIC_ prefix
const apiKey = process.env.STRIPE_SECRET_KEY;

只有当环境变量的值确实需要公开时才加 NEXT_PUBLIC_ 前缀,比如可以公开发布的 Stripe key、统计 ID 之类的。API 密钥、数据库地址、以及任何只该服务端用的东西,绝对不要加这个前缀。

总结

方案 防御对象
滑动窗口限速(Upstash) 接口滥用、客户端重试循环、暴力破解
已登录操作按用户限速 IP 轮换和共享 IP 导致的限速不准确
API 边界处用 Zod 验证 畸形或异常输入进入数据库
Cookie 认证接口限制 CORS 跨站请求利用已登录态
密钥不加 NEXT_PUBLIC_ API 密钥泄露到客户端包

速率限制和输入验证解决的是不同问题,两者缺一不可。速率限制控制频率,验证控制内容。只上一个另一个的接口只是换了一种暴露方式而已。

我现在做的每个 SaaS 项目,公开接口都会上这套组合拳:Upstash 限速、Zod 边界验证。

你们有没有在生产环境被高频请求冲击过,真的需要上速率限制的经历?评论区聊聊 👇