Session、Cookie、Token 三种认证方式详解

一、从一个场景说起

你在浏览器里登录了淘宝,关掉电脑,第二天打开浏览器,发现还是登录状态。你掏出手机扫码登录微信网页版,手机确认后,网页自动跳转。你在公司电脑上打开了"记住密码"的功能,两周后再访问,依然不需要重新登录。

这三种场景,恰好对应了 Web 认证的三种核心机制:CookieSessionToken。它们看起来都在解决"记住用户是谁"的问题,但背后的原理和适用场景截然不同。

Token、Session、Cookie的使用过程对比


二、Cookie —— 浏览器的小纸条

2.1 什么是 Cookie?

Cookie 是服务器让浏览器存储在用户本地的一小段数据(通常不超过 4KB)。浏览器在后续请求同一域名时,会自动把这段数据带在请求头里发给服务器。

你可以把它理解成:服务员在你手上盖了个章,你下次再来,服务员一看你的手就知道你是谁。

1
2
3
4
5
6
7
8
9
10
11
12
1. 浏览器请求服务器(首次访问)
GET / HTTP/1.1
Host: example.com

2. 服务器在响应头中设置 Cookie
HTTP/1.1 200 OK
Set-Cookie: username=zhangsan; HttpOnly; Secure; Max-Age=3600

3. 浏览器存储 Cookie,后续请求自动携带
GET /api/user HTTP/1.1
Host: example.com
Cookie: username=zhangsan
属性 作用 说明
HttpOnly 禁止 JavaScript 访问 防止 XSS 攻击窃取 Cookie
Secure 仅 HTTPS 传输 防止中间人截获
SameSite 控制跨站请求携带 Strict/Lax/None,防 CSRF
Domain 指定生效域名 可跨子域共享
Path 指定生效路径 默认当前路径
Max-Age / Expires 设置过期时间 不设则为会话 Cookie,关闭浏览器即失效

2.4 代码示例

服务端设置 Cookie(Node.js Express):

1
2
3
4
5
6
7
// 设置一个 HttpOnly + Secure 的 Cookie
res.cookie('sessionId', 'abc123', {
httpOnly: true, // JS 无法读取
secure: true, // 仅 HTTPS
sameSite: 'lax', // 防止 CSRF
maxAge: 24 * 60 * 60 * 1000 // 24 小时
});
  • 容量小:单个 Cookie 不超过 4KB,每个域名 Cookie 总数有限制
  • 每次请求都携带:会增加请求体积,浪费带宽
  • 跨域限制:默认不同源不共享 Cookie(虽然可以配置)
  • CSRF 风险:浏览器自动携带 Cookie 的特性可能被利用

三、Session —— 服务器端的用户档案

3.1 什么是 Session?

Session 是服务器为每个用户维护的会话数据,存储在服务端。浏览器只保存一个 Session ID(通常放在 Cookie 中),服务器通过这个 ID 来查找对应的用户数据。

可以理解为:你去健身房办了个卡,卡上只有一个编号(Session ID),你的个人信息、会员等级、课时余额都存储在健身房的系统里(服务端 Session)。

3.2 Session 认证流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
用户登录


服务器验证用户名密码


服务器创建 Session ──► 存入 Redis/数据库/内存


返回 Set-Cookie: sessionId=xxxx


浏览器存储 sessionId


后续请求携带 Cookie


服务器用 sessionId 查找 Session


获取用户信息,处理请求

3.3 代码示例

Express + express-session:

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
const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
store: new RedisStore({ client: redisClient }), // 存 Redis
secret: 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: 30 * 60 * 1000 // 30 分钟过期
}
}));

// 登录接口
app.post('/login', (req, res) => {
// 验证密码...
req.session.user = { id: 1, name: 'zhangsan', role: 'admin' };
res.json({ success: true });
});

// 获取当前用户
app.get('/me', (req, res) => {
if (req.session.user) {
res.json(req.session.user);
} else {
res.status(401).json({ error: '未登录' });
}
});

// 退出登录
app.post('/logout', (req, res) => {
req.session.destroy(err => {
res.json({ success: true });
});
});

3.4 Session 存储方案

方案 优点 缺点 适用场景
内存 速度快 重启丢失、无法跨进程共享 开发环境
Redis 高性能、支持分布式、可设过期 需要额外运维 生产环境首选
数据库 持久化 性能较差 需要审计日志的场合

3.5 Session 的优缺点

优点:

  • 用户数据存在服务端,相对安全
  • 服务端可以随时主动失效(踢人下线)
  • Session 中可存储任意数据,不受 4KB 限制

缺点:

  • 服务端需要存储,用户量大时开销大
  • 分布式系统需要共享 Session(Redis 集中存储)
  • 依赖 Cookie 传递 Session ID,跨域麻烦
  • 移动 App 对 Cookie 支持不友好

四、Token —— 无状态的通行证

4.1 什么是 Token?

Token 是一段由服务器签发的加密字符串,用户信息被编码在 Token 自身之中。服务器不需要存储任何东西,只要验证 Token 的签名就能确认用户身份。

最常见的实现是 JWT(JSON Web Token)

可以理解为:你去参加一个会议,主办方发给你一个带有防伪标识的胸牌(Token),上面写了你的名字和权限。你每次进入会场,保安只要检查胸牌的真伪(验签),不需要去后台系统查你是谁。

4.2 JWT 的结构

JWT 由三部分组成,用 . 分隔:

1
2
3
Header.Payload.Signature

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.dGhpcyBpcyBub3QgYSByZWFsIHNpZ25hdHVyZQ==

Header(头部):声明算法和类型

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

Payload(负载):存放用户数据(声明)

1
2
3
4
5
6
7
{
"sub": "1", // 用户ID
"name": "zhangsan", // 用户名
"role": "admin", // 角色
"iat": 1683993600, // 签发时间
"exp": 1684080000 // 过期时间
}

Signature(签名):防篡改

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

⚠️ 重要提醒:JWT 的 Payload 只是 Base64 编码,任何人都可以解码查看内容。绝不要在 Payload 中存放密码等敏感信息!

4.3 Token 认证流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
用户登录


服务器验证用户名密码


服务器生成 JWT(包含用户信息+签名)


返回 { accessToken, refreshToken }


前端存储 Token(内存/localStorage/Cookie)


后续请求在 Authorization 头中携带 Token
Authorization: Bearer <accessToken>


服务器验证签名 ──► 通过则处理请求

▼ (Token 过期时)
前端用 refreshToken 换取新的 accessToken

4.4 代码示例

生成与验证 JWT(Node.js):

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
56
57
58
59
60
61
62
const jwt = require('jsonwebtoken');

const SECRET = 'your-256-bit-secret';
const REFRESH_SECRET = 'your-refresh-secret';

// 登录 — 生成 Token
app.post('/login', async (req, res) => {
const user = await validateUser(req.body.username, req.body.password);

// Access Token:短期有效(15分钟)
const accessToken = jwt.sign(
{ sub: user.id, name: user.name, role: user.role },
SECRET,
{ expiresIn: '15m' }
);

// Refresh Token:长期有效(7天)
const refreshToken = jwt.sign(
{ sub: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);

res.json({ accessToken, refreshToken });
});

// 验证 Token 的中间件
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '缺少 Token' });
}

try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, SECRET);
next();
} catch (err) {
res.status(401).json({ error: 'Token 无效或已过期' });
}
}

// 刷新 Token
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
const newAccessToken = jwt.sign(
{ sub: payload.sub },
SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).json({ error: 'Refresh Token 无效' });
}
});

// 使用中间件保护路由
app.get('/me', authMiddleware, (req, res) => {
res.json(req.user);
});

4.5 Access Token 与 Refresh Token

Access Token Refresh Token
有效期 短(15分钟~2小时) 长(7天~30天)
用途 访问API 换取新的 Access Token
存储位置 内存(推荐) HttpOnly Cookie
发送频率 每次请求 仅在刷新时

4.6 Token 存储位置之争

方案 XSS 风险 CSRF 风险 持久性 建议
localStorage ❌ 高(JS可读) ✅ 安全 ✅ 持久 不推荐
sessionStorage ❌ 高(JS可读) ✅ 安全 ❌ 关闭即丢 不推荐
HttpOnly Cookie ✅ 安全 ❌ 需防 CSRF ✅ 可控 推荐
内存变量 ✅ 安全 ✅ 安全 ❌ 刷新即丢 Access Token 推荐

最佳实践:Access Token 存在内存中(刷新页面重新换取),Refresh Token 存在 HttpOnly Cookie 中。

4.7 Token 的优缺点

优点:

  • 无状态:服务器不需要存储会话信息,易于水平扩展
  • 跨域友好:不依赖 Cookie,任何域名都可以携带
  • 移动端友好:App 原生支持 Header 传参
  • 跨服务:同一 Token 可在多个微服务中验证

缺点:

  • 无法主动撤销:Token 签发后在其有效期内一直有效(需要黑名单机制补救)
  • 体积较大:相比 Session ID,JWT 在每次请求中占用更多带宽
  • Payload 裸露:敏感信息不能放入 JWT
  • 续期复杂:需要双 Token + 刷新机制

五、三者核心对比

维度 Cookie Session Token (JWT)
数据存储位置 浏览器 服务端 客户端(自包含)
状态性 配合使用 有状态 无状态
扩展性 需要共享存储 天然支持分布式
移动端支持 优秀
安全性 看配置 服务端可控 看存储方式
CSRF 风险 取决于存储位置
XSS 风险 HttpOnly 可防 视存储方式而定
跨域支持 有限 有限 原生支持
单次请求开销 小(仅传 ID) 较大(完整 Token)
主动失效 不支持 支持(删除 Session) 困难(需黑名单)

六、面试高频问题

Q1:JWT 是无状态的,"无状态"到底是什么意思?

无状态(Stateless) 指的是服务器不需要在内存或数据库中保存用户的会话数据。每个请求自包含——JWT 本身就携带用户信息,服务器只要验证签名就能确认身份。

这对水平扩展意义重大:请求打到任何一台服务器都能被正确处理,不需要共享 Session 存储。而传统 Session 模式下,如果请求被负载均衡到另一台机器,那台机器上没有对应 Session,用户就被认为"未登录"。

Q2:JWT 怎么实现"主动失效"(比如踢人下线)?

JWT 的天然缺陷是无法主动撤销。常见补救方案:

  1. 短期有效期 + Refresh Token 轮换:Access Token 设 15 分钟过期,过期后用 Refresh Token 换取新的。要踢人时,将 Refresh Token 加入黑名单,用户最多 15 分钟内就无法再续期。

  2. Token 黑名单(Redis):将需要失效的 JWT 的 jti(JWT ID)存入 Redis,过期时间设为 JWT 的剩余有效期。中间件验证时先查黑名单。

  3. 版本号/时间戳法:在用户表中维护一个 tokenVersion 字段。JWT 签发时嵌入版本号。踢人时递增该字段,旧版本号的 JWT 全部失效。

CSRF(跨站请求伪造):攻击者诱导用户在已登录目标网站的情况下,访问恶意页面,恶意页面会自动向目标网站发起请求。因为浏览器会自动携带目标网站的 Cookie,所以攻击者的请求会以用户身份执行。

示例:你在 bank.com 登录了,然后不小心点开了一个恶意网站。这个网站里有一个隐藏的 <img src="https://bank.com/transfer?to=hacker&amount=10000">。浏览器加载这张"图片"时,会自动携带 bank.com 的 Cookie,转账就执行了。

SameSite 属性的防御原理:

  • SameSite=Strict:完全禁止跨站发送 Cookie。最严格,但用户从邮件点链接进入网站时也不会带 Cookie,体验差。
  • SameSite=Lax:只在顶级导航(如点击链接)时发送 Cookie,在 <img><iframe>、AJAX 等子资源请求中不发送。这是大部分场景的最佳选择。
  • SameSite=None:不做限制,但必须搭配 Secure

这是一个经典的"安全性 vs 便利性"的权衡:

localStorage 的问题:任何 JavaScript 代码都能读取 localStorage.getItem('token')。一旦网站有 XSS 漏洞(比如某个第三方脚本被污染),攻击者就能直接窃取 Token。

HttpOnly Cookie 的优势HttpOnly 标记使 Cookie 完全对 JavaScript 不可见。即使有 XSS 漏洞,攻击者也无法直接读取 Token。但代价是引入了 CSRF 风险,需要用 SameSite + CSRF Token 做双重防护。

结论:HttpOnly + Secure + SameSite 的 Cookie 更安全。 理想的组合是 Access Token 存内存、Refresh Token 存 HttpOnly Cookie。

  • Session 模式:服务端的 Session 还在(直到过期),但客户端丢失了 Session ID,无法再匹配。用户表现为"被登出",需要重新登录。
  • JWT 模式(Token 存在 Cookie):Token 丢失,用户需要重新登录。
  • JWT 模式(Token 存在 localStorage):清除 Cookie 不会清除 localStorage,Token 仍然存在,用户不受影响。

这也是很多网站"清除 Cookie"后发现登录状态还在的原因——Token 存在了 localStorage。

Q6:"记住我"功能怎么实现?

核心思路:将"登录"和"记住密码"分开处理。

  1. 用户勾选"记住我"登录。
  2. 服务端生成一个特殊的长期 Token(如 remember_token),存入数据库,同时设为 HttpOnly Cookie,maxAge 设为 7~30 天。
  3. 用户的短期 Session 过期后,系统检测到 remember_token,自动为用户重建 Session,无需用户重新输入密码。
  4. 用户"退出登录"时,服务端删除数据库中对应的 remember_token,同时清除 Cookie。
1
2
3
4
5
6
7
8
9
10
11
// 登录接口中的"记住我"逻辑
if (req.body.rememberMe) {
const rememberToken = crypto.randomBytes(32).toString('hex');
await db.users.update(user.id, { rememberToken });
res.cookie('rememberToken', rememberToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 天
});
}

Q7:什么是 Session 固定攻击(Session Fixation)?如何防御?

攻击流程:

  1. 攻击者访问网站,获得一个 Session ID(如 sessionId=abc123)。
  2. 攻击者诱导用户用这个 Session ID 访问网站(如发送链接 https://site.com?sessionId=abc123)。
  3. 用户被诱导后用这个 Session ID 登录,服务器将用户身份和 abc123 绑定。
  4. 攻击者自己用 sessionId=abc123 访问网站,此时他以用户身份登录了。

防御方法:

  • 登录后更换 Session ID:这是最重要的防御。express-sessionreq.session.regenerate() 可以做到。
  • 不使用 URL 传递 Session ID:仅用 Cookie。
  • 设置合理的 Cookie 属性HttpOnly + Secure + SameSite

Q8:在 SPA 中如何优雅地处理 Token 刷新?

一个成熟的方案:

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
// Axios 拦截器实现无感刷新
let isRefreshing = false;
let failedQueue = [];

function processQueue(error, token = null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token);
});
failedQueue = [];
}

axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;

if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 已经有刷新请求在进行中,排队等待
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return axios(originalRequest);
});
}

originalRequest._retry = true;
isRefreshing = true;

try {
const { accessToken } = await refreshAccessToken(); // 用 Refresh Token 换新
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
// 刷新失败,跳转登录页
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}

return Promise.reject(error);
}
);

关键点:

  • 并发请求排队:多个请求同时 401 时,只发一次刷新请求,其他排队等待。
  • 重试标记_retry 防止刷新接口本身 401 导致死循环。
  • 失败兜底:刷新失败统一跳转登录页。

七、总结

这三个概念不是"三选一"的对立关系,而是不同层次的解决方案

  • Cookie 是传输工具——浏览器自动携带的存储机制
  • Session 是认证模式——服务端存储会话状态
  • Token 是认证模式——客户端自包含凭证

实际项目中它们经常混用:Session ID 通过 Cookie 传输,JWT 也可以用 HttpOnly Cookie 存储。理解它们各自的职责边界,比背诵"区别"更重要。

面试一句话总结

Session 是"服务器存状态,客户端存钥匙";JWT 是"客户端自己带着身份证";Cookie 是"浏览器自动帮他们传递的工具"。


面试关键词速记卡:

概念 一句话
Cookie 浏览器自动携带的小段数据存储,4KB 限制
Session 服务端存用户数据,客户端只存 Session ID
JWT 自包含的加密令牌,无状态,天然支持分布式
CSRF 利用浏览器自动带 Cookie 的特性跨站伪造请求
SameSite Cookie 属性,控制跨站请求是否携带 Cookie
Refresh Token 长期 Token,专门用来换短期 Access Token
HttpOnly Cookie 属性,禁止 JS 读取,防 XSS 窃取