再見 localStorage :2025 年用 Cookie 托管 JWT 的 SPA 實戰
當你忽然發現:項目里把令牌丟進 localStorage,其實這些年一直暴露在 XSS 風險下,那表情……你懂的。
把同一枚令牌放進 HttpOnly Cookie,立刻有三件好事發生:
- JS 讀不到 —— 注入腳本再也偷不走你的身份;
- 瀏覽器自動帶 —— 每個請求都自帶 Cookie,無需你手寫
Authorization頭; - 安全標記可用 ——
Secure/SameSite等旗標能有效降低 CSRF 與竊聽風險。
一句話:多一點點配置,安心很多年。
服務器端:在 Node.js 里簽發 & 刷新令牌
極簡 Express 骨架
// server/index.mjs
import express from 'express';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
const app = express();
app.use(express.json());
app.use(cookieParser());
// 將 CSRF Token 放在 Cookie 中(雙重提交策略)
app.use(csurf({ cookie: true }));
// 提供一個拿 CSRF Token 的小接口(從 header 里讀)
app.get('/api/csrf-token', (req, res) => {
res.setHeader('x-csrf-token', req.csrfToken());
res.sendStatus(204);
});
我們解析 JSON、讀取 Cookie,并讓
csurf在瀏覽器里種下一枚 CSRF Cookie。此后所有不安全方法(POST/PUT/DELETE…)都必須回傳那枚 Token,否則請求會被拒。
“登錄”接口:發出一對 Access/Refresh
import jwt from 'jsonwebtoken';
const isProd = process.env.NODE_ENV === 'production';
app.post('/api/token', (req, res) => {
const { username, password } = req.body;
// 1) 校驗賬號口令(略)
const accessToken = jwt.sign({ sub: username }, process.env.ACCESS_SECRET, { expiresIn: '5m' });
const refreshToken = jwt.sign({ sub: username }, process.env.REFRESH_SECRET, { expiresIn: '1d' });
res
.cookie('access_token', accessToken, {
httpOnly: true, sameSite: 'strict', secure: isProd, maxAge: 5 * 60 * 1000
})
.cookie('refresh_token', refreshToken, {
httpOnly: true, sameSite: 'strict', secure: isProd, maxAge: 24 * 60 * 60 * 1000
})
.json({ user: username, status: 'active' });
});
密碼驗證通過后,我們生成兩枚令牌。它們都寫進 HttpOnly Cookie:腳本拿不到,但瀏覽器會在每次請求時自動附帶。
“刷新”接口:用 refresh 換一對新票
app.post('/api/token/refresh', (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.sendStatus(401);
jwt.verify(refreshToken, process.env.REFRESH_SECRET, (err, decoded) => {
if (err) return res.sendStatus(403);
const newAccess = jwt.sign({ sub: decoded.sub }, process.env.ACCESS_SECRET, { expiresIn: '5m' });
const newRefresh = jwt.sign({ sub: decoded.sub }, process.env.REFRESH_SECRET, { expiresIn: '1d' });
res
.cookie('access_token', newAccess, {
httpOnly: true, sameSite: 'strict', secure: isProd, maxAge: 5 * 60 * 1000
})
.cookie('refresh_token', newRefresh, {
httpOnly: true, sameSite: 'strict', secure: isProd, maxAge: 24 * 60 * 60 * 1000
})
.json({ ok: true });
});
});
從 Cookie 里取出
refresh_token校驗;合法就“續簽”一對新票塞回 Cookie;不合法就要求重新登錄。
React 端:與 Cookie & CSRF 一起生活
先拿到 CSRF Token
async function getCSRF() {
const res = await fetch('/api/csrf-token', { credentials: 'include' });
return res.headers.get('x-csrf-token');
}中間件已經種下了 CSRF Cookie。我們調用一個極小的接口,就能從 響應頭里拿到同值的 Token,隨后用于 POST/PUT/DELETE 等請求的頭部。
登錄
const loginUser = async (e) => {
e.preventDefault();
const csrf = await getCSRF();
const res = await fetch('/api/token', {
method: 'POST',
credentials: 'include', // 雙向攜帶 Cookie
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value
})
});
if (res.ok) {
const data = await res.json();
// 公開信息放本地存儲沒問題;敏感票據一律別放
localStorage.setItem('username', data.user);
} else {
alert('Wrong login or password');
}
};靜默續期(Auto-Refresh)
useEffect(() => {
const t = setInterval(async () => {
const csrf = await getCSRF();
await fetch('/api/token/refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }
});
}, 4.5 * 60 * 1000); // 比 5 分鐘過期稍早一點
return () => clearInterval(t);
}, []);
每 4.5 分鐘悄悄續一把簽。用戶一路流暢,無“會話過期請重新登錄”的打擾。
五個加分場景
1)大文件上傳
const uploadFile = async (file) => {
const data = new FormData();
data.append('file', file);
await fetch('/api/upload', {
method: 'POST',
body: data,
credentials: 'include'
});
};
Cookie 自動攜帶,函數無需關心 Token/頭部,邏輯更干凈。
2)Next.js 的服務端渲染(SSR)
export async function getServerSideProps({ req }) {
const token = req.cookies.access_token;
// 代表用戶預取私有數據……
return { props: { /* … */ } };
}
SSR 階段令牌在
req.cookies,你可以提前取數,把“成品 HTML”送到瀏覽器。
3)WebSocket 實時聊天
瀏覽器升級為 WS 時,許多環境不會自動附帶 Cookie。通常做法:把 Access Token 掛在查詢串上,一次性校驗。
wss://site/chat?token=abc.def.ghi后端握手時校驗通過即放行,之后保持長連。
4)微服務前的 API Gateway
網關讀取 Cookie,把令牌轉抄進
Authorization頭后轉發請求。內部服務無需理解瀏覽器細節,只需照常驗簽。
5)移動端(React Native)
移動端沒有 HttpOnly Cookie。把 Refresh Token 存在系統提供的 Keychain/Keystore 里是次優解: 它會加密,并隔離其他 App 訪問。
要點回顧(Takeaways)
- HttpOnly Cookie 能把你的 JWT 隔絕于 XSS 之外;
- CSRF Token 仍然必須:所有“非冪等”請求都要帶;
- 定期刷新讓會話持續而不打擾用戶;
- 從 Django → Node.js 的遷移,主要是服務端配線;瀏覽器側邏輯幾乎不變。






























