最近在做一个 Next.js 项目,用到了 App Router。项目里既有客户端渲染的页面(比如打卡页面需要交互),也有服务端渲染的页面(比如财务统计页面需要 SEO)。
一开始因为是纯前端渲染, 想着用 localStorage 存 token,但这样服务端渲染的页面就无法验证了,后改为 Cookie 验证. 这样客户端和服务端都能读,而且浏览器会自动携带。
用户登录
↓
设置 HttpOnly Cookie (服务端设置)
↓
┌─────────────────┬──────────────────┐
│ 客户端页面 │ 服务端页面 │
│ ('use client') │ (default) │
│ │ │
│ fetch/axios │ cookies() API │
│ 自动携带Cookie │ 直接读取Cookie │
│ ↓ │ ↓ │
│ API接受Cookie验证│ requireAuth()验证 │
└─────────────────┴──────────────────┘
改造登录 API(app/api/auth/login/route.ts):
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await validateUser(body.username, body.password);
if (user) {
const token = generateToken(user);
// 服务端设置 HttpOnly Cookie
cookies().set({
name: 'm_token',
value: token,
httpOnly: true, // 防止 JS 读取,更安全
secure: process.env.NODE_ENV === 'production', // 生产环境强制 HTTPS
sameSite: 'lax', // CSRF 保护
maxAge: 60 * 60 * 24 * 7, // 7 天
path: '/',
});
return NextResponse.json({
ret: 0,
data: { user },
message: '登录成功',
});
}
return NextResponse.json(
{
ret: 1001,
message: '用户名或密码错误',
},
{ status: 401 },
);
}
关键点:
httpOnly: true - JS 无法读取,防止 XSS 攻击窃取 tokensecure: true - 生产环境强制 HTTPS 传输sameSite: 'lax' - 防止 CSRF 攻击客户端只需要调用 API(app/login/page.tsx):
'use client';
const onSubmit = async (data) => {
// 直接调用 API,Cookie 由服务端设置
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include', // 重要:允许发送和接收 Cookie
});
const result = await response.json();
if (result.ret === 0) {
// Cookie 已经自动设置好了
window.location.href = '/';
} else {
toast.error(result.message);
}
};
关键点:
credentials: 'include' - 必须加这个,否则浏览器不会发送/接收 Cookie如果用 axios,全局配置一次即可(lib/request.ts):
import axios from 'axios';
const request = axios.create({
baseURL: '/api',
timeout: 10000,
withCredentials: true, // 全局启用 Cookie
});
// 不需要拦截器手动加 Authorization 头了
// Cookie 会自动携带
export default request;
和之前一样,封装 requireAuth(lib/serverAuth.ts):
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getUserByToken } from '#/app/api/models/users';
export async function requireAuth() {
const cookieStore = cookies();
const token = cookieStore.get('m_token')?.value;
if (!token) {
redirect('/login');
}
const user = await getUserByToken(token);
if (!user) {
redirect('/login');
}
return user;
}
使用简单:
// 任何服务端页面
export default async function BalancePage() {
await requireAuth();
// 业务逻辑...
}
API 路由也能直接读 Cookie(app/api/lib/verifyToken.ts):
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthError';
}
}
export async function verifyToken(request: NextRequest) {
const cookieStore = cookies();
const token = cookieStore.get('m_token')?.value;
if (!token) {
throw new AuthError('未登录');
}
const user = await getUserByToken(token);
if (!user) {
throw new AuthError('Token 无效');
}
return user;
}
API 路由使用:
export async function GET(request: NextRequest) {
try {
const user = await verifyToken(request); // 从 Cookie 读取
// 业务逻辑...
return NextResponse.json({ ret: 0, data: {...} });
} catch (error) {
return handleApiError(error, '获取数据');
}
}
登出也在服务端处理(app/api/auth/logout/route.ts):
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST() {
// 删除 Cookie
cookies().delete('m_token');
return NextResponse.json({
ret: 0,
message: '登出成功',
});
}
客户端调用:
'use client';
const handleLogout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
window.location.href = '/login';
};
忘记设置 credentials: 'include'
fetch/axios 请求必须加这个,否则 Cookie 不会发送。
HttpOnly Cookie 在客户端读不到
这是特性不是 bug!HttpOnly 就是为了防止 JS 读取。如果需要客户端读取用户信息,让 API 返回用户数据,不要读 Cookie。
开发环境 Cookie 丢失
localhost 不支持 secure: true,要在配置里判断环境:
secure: process.env.NODE_ENV === 'production';
跨域问题
如果前后端分离(不同域名),需要配置 CORS:
// next.config.js
async headers() {
return [{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: 'https://your-frontend.com' },
],
}];
}
Cookie 的 path 必须是 /
否则子路由读不到。
// 1. 用户提交登录表单
const onSubmit = async (data) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include', // ← 关键
});
const result = await response.json();
if (result.ret === 0) {
window.location.href = '/'; // Cookie 已设置,直接跳转
}
};
// 2. 访问需要登录的页面
export default async function ProtectedPage() {
const user = await requireAuth(); // ← 自动从 Cookie 验证
return <div>欢迎,{user.username}</div>;
}
// 3. 客户端 API 请求
const fetchData = async () => {
// Cookie 自动携带,不需要手动处理
const response = await fetch('/api/data', {
credentials: 'include', // ← 关键
});
const data = await response.json();
};
// 4. 登出
const logout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include', // ← 关键
});
window.location.href = '/login';
};
生产环境必须用 HTTPS
secure: true 强制加密传输。
设置合理的过期时间
我设的 7 天,根据业务调整。敏感操作可以设置短期 token。
启用 SameSite 防护
sameSite: 'lax' 或 'strict' 防止 CSRF。
定期刷新 Token
可以在中间件里检查 token 过期时间,快到期自动刷新。
记录登录日志
异常登录及时报警。
Refresh Token 机制
Access Token 短期(1 小时),Refresh Token 长期(7 天),快过期自动刷新。
记住我功能
勾选"记住我"设置更长的过期时间(30 天)。
多设备登录管理
Token 绑定设备信息,支持查看/踢出其他设备。
中间件统一拦截
用 Next.js middleware 在路由层面验证,不用每个页面写 requireAuth()。
纯 Cookie 方案的优势:
统一数据源 - 不用维护 localStorage 和 Cookie 两套
更安全 - HttpOnly + Secure + SameSite 三重保护
自动携带 - 浏览器自动在请求中带上 Cookie
服务端可读 - 完美支持 Next.js 混合渲染
代码更简洁 - 不需要手动管理 token 存储和发送