Node Express+React的Http Only Cookie+JWT认证遇SameSite及跨域问题
问题背景
我有一个基于Node Express的JWT身份验证服务器,原本前端把token存在localStorage,现在要切换到Http Only Cookie方案。服务器部署在Heroku,React前端在Netlify。已经配置好CORS,认证路由里用res.cookie设置了httpOnly: true、secure: true、sameSite: None,前端fetch请求也加了credentials: 'include',但遇到两个问题:
- 设置
sameSite: None时,Heroku日志报错:Error creating JWT token: TypeError: option sameSite is invalid,JWT无法创建 - 把
sameSite设为lax或strict时,JWT能发送到浏览器,但请求/users等接口时Cookie不会回传给服务器
相关代码
App.js
app.set("trust proxy", 1); // trust first proxy const corsOptions = { origin: [ "http://localhost:3000", "http://localhost:3001", "http://localhost:3002", "https://techhype.netlify.app", "https://techhype.no", "https://techhype-server-06db1c82ee3e.herokuapp.com", ], preflightContinue: false, optionsSuccessStatus: 204, allowedHeaders: ["Content-Type", "Authorization"], methods: ["GET", "POST", "PUT", "DELETE"], credentials: true, }; app.use(cors(corsOptions));
auth.js路由
router.post("/login", authLimiter, jsonParser, async (req, res, next) => { const { email, password } = req.body; if (email == null) { return res.jsend.fail({ statusCode: 400, email: "Email is required." }); } if (password == null) { return res.jsend.fail({ statusCode: 400, password: "Password is required.", }); } // Retrieve the user by email const user = await userService.getOneByEmail(email); if (!user) { return res.jsend.fail({ statusCode: 400, result: "Incorrect email or password", }); } // Check if the user is verified if (!user.Verified) { return res.jsend.fail({ statusCode: 400, result: "User is not verified. Please check your email for the verification link.", }); } // Verify the password crypto.pbkdf2(password, user.Salt, 310000, 32, "sha256", function (err, hashedPassword) { if (err) { return res.jsend.error("Error in password encryption"); } if (!crypto.timingSafeEqual(user.EncryptedPassword, hashedPassword)) { return res.jsend.fail({ result: "Incorrect password" }); } try { const token = jwt.sign( { id: user.id, email: user.Email, role: user.Role, Verified: user.Verified, name: user.FirstName, }, process.env.TOKEN_SECRET, { expiresIn: process.env.JWT_EXPIRATION } // or "3600" (expressed in seconds) ); const refreshToken = jwt.sign( { id: user.id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.JWT_EXPIRATION_LONG } // or "604800" (expressed in seconds) ); console.log("Token creation: ", token); // Set the JWT and refresh token in HTTP-only cookies res.cookie('token', token, { httpOnly: true, secure: true, //true in production only sameSite: 'None', // can be 'strict' or 'lax or None', }); res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'None', }); return res.jsend.success({ result: "You are logged in", id: user.id, email: user.Email, name: user.FirstName, role: user.Role, verified: user.Verified }); } catch (err) { console.error("Error creating JWT token:", err); return res.jsend.error("Something went wrong with creating the JWT token"); } }); });
isAuth中间件
const jwt = require("jsonwebtoken"); // Middleware function to determine if the API endpoint request is from an authenticated user function isAuth(req, res, next) { const token = req.cookies.token; console.log("Received Token:", token); // Log the received token if (token) { try { const decodedToken = jwt.verify(token, process.env.TOKEN_SECRET); req.user = decodedToken; // Attach user data to the request object next(); } catch (error) { res.status(401).json({ status: "fail", statusCode: 401, error: "Authentication failed" }); } } else { res.status(401).json({ status: "fail", statusCode: 401, error: "No token provided" }); } } module.exports = isAuth;
React前端获取用户数据代码
const fetchUserData = async () => { try { const response = await fetch(`${process.env.REACT_APP_API_URL}/users`, { method: 'GET', credentials: 'include', // Ensure cookies are included }); const data = await response.json(); // Always parse JSON to check for error details console.log("user data response:", data); if (response.ok) { updateUser(data.data.result.user); } else { console.error('Failed to fetch user data', data); throw new Error(data.message || 'Failed to fetch user data'); } } catch (error) { console.error('Fetch user data error:', error); } };
解决方案
1. 修复sameSite: None无效的问题
报错原因是你的Express版本低于4.16.0,该版本才正式支持sameSite: 'None'参数。两种解决方式:
- 升级Express:执行命令
npm install express@latest,确保版本≥4.16.0 - 兼容旧版本写法:手动构造
Set-Cookie响应头,替代res.cookie:
// 替换原来的res.cookie代码块 const tokenCookie = `token=${token}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${parseInt(process.env.JWT_EXPIRATION)}`; const refreshTokenCookie = `refreshToken=${refreshToken}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${parseInt(process.env.JWT_EXPIRATION_LONG)}`; res.setHeader('Set-Cookie', [tokenCookie, refreshTokenCookie]);
2. 确保跨域Cookie回传的完整配置
- 服务器端:
- 确认
app.set("trust proxy", 1);已开启,Heroku作为反向代理,开启后Express才能识别真实请求协议为HTTPS,保证Secure属性生效(浏览器要求SameSite=None必须搭配Secure) - CORS的
origin必须精确匹配前端域名,不能用通配符,credentials: true必须保持开启
- 确认
- 前端:
- 确认
fetch的credentials: 'include'正确设置,你的代码已符合要求
- 确认
- 环境变量:在Heroku设置
NODE_ENV=production,确保secure: true只在生产环境生效(本地开发可设为false)
3. 修正后的res.cookie写法(适用于新版Express)
res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'None', path: '/', // 确保Cookie在所有接口路径下可用 maxAge: parseInt(process.env.JWT_EXPIRATION) * 1000 // 和JWT过期时间保持一致 }); res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'None', path: '/', maxAge: parseInt(process.env.JWT_EXPIRATION_LONG) * 1000 });
排查要点
- 查看浏览器开发者工具→Application→Cookies,确认Cookie的
SameSite为None、Secure已勾选、Domain正确指向Heroku服务器域名 - 检查Network标签:登录请求的Response Headers是否包含正确的
Set-Cookie,/users请求的Request Headers是否携带Cookie字段 - 关闭广告拦截类浏览器插件,部分插件会阻止跨域Cookie发送
内容的提问来源于stack exchange,提问作者Glenn Larsen
相关产品推荐
相关产品推荐

