Cookies、Session、Token:一次性讲清状态保持(附实例+代码+项目实战)
在Web开发中,"状态保持"是个绕不开的核心问题。你有没有想过:为什么登录一次后,网站就知道你是谁,能记住你的购物车和偏好设置?这背后其实就是Cookies、Session、Token这三大技术在默默发力。
很多开发者刚接触时总会混淆这三者:什么时候用Cookie?Session和Token的区别在哪?分布式系统为什么偏爱Token?今天这篇文章,从原理到代码实现,再到项目实战,一次性给你讲透!
一、为什么需要状态保持?
首先要明确一个关键前提:HTTP协议是无状态的。这意味着客户端(浏览器、APP)和服务器之间的每一次请求都是独立的,服务器不会主动记住上一次请求的任何信息。
举个例子:你第一次访问某电商网站,浏览商品后添加了几件到购物车;如果没有状态保持,当你刷新页面或再次发起请求时,服务器根本不知道"刚才是谁加了购物车",购物车数据就会丢失。
再比如登录功能:你输入账号密码验证通过后,下一次访问个人中心时,服务器必须能识别出"这个请求来自刚才登录成功的用户",否则就需要反复登录——这显然是无法接受的。
所以,状态保持的核心目标就是:让服务器在多次请求中,识别出同一个客户端,从而关联起该客户端的上下文信息(如登录状态、购物车数据、用户偏好等)。
核心痛点:HTTP无状态 → 服务器无法关联多次请求 → 需通过额外技术实现状态绑定
二、Cookie:浏览器时代的最初方案
Cookie是最早解决状态保持问题的技术,本质是服务器发送给客户端的一小段文本信息。客户端(主要是浏览器)会将其保存起来,之后每次向该服务器发起请求时,都会自动带上这段文本,让服务器识别身份。
类比理解:你去酒店入住时,前台给你一张房卡(Cookie),之后每次进出电梯、开门,只要出示房卡,酒店工作人员(服务器)就知道你是已入住的客人。
正面示例:服务器设置 Cookie
下面用Python Flask框架演示服务器如何设置Cookie,以及客户端如何自动携带。首先确保已安装Flask:pip install flask
服务端(Python Flask) :
from flask import Flask, make_response, request
app = Flask(__name__)
# 1. 服务器设置Cookie的接口
@app.route('/set_cookie')
def set_cookie():
# 创建响应对象
resp = make_response("服务器已设置Cookie")
# 设置Cookie:key为"user_id",value为"123456",max_age为3600秒(1小时有效期)
resp.set_cookie(
key="user_id",
value="123456",
max_age=3600, # 有效期,单位秒
path="/", # 生效路径,/表示整个网站都生效
httpOnly=True # 关键!禁止前端JS读取,防止XSS攻击
)
return resp
# 2. 验证客户端是否携带Cookie的接口
@app.route('/get_cookie')
def get_cookie():
# 从请求中获取Cookie
user_id = request.cookies.get("user_id")
if user_id:
return f"服务器识别到你的身份:user_id={user_id}"
else:
return "未识别到身份,请先访问/set_cookie设置Cookie"
if __name__ == '__main__':
app.run(debug=True)
测试流程:
- 启动服务后,访问
http://127.0.0.1:5000/get_cookie,会提示"未识别到身份" - 访问
http://127.0.0.1:5000/set_cookie,服务器返回"已设置Cookie" - 再次访问
http://127.0.0.1:5000/get_cookie,会显示识别到的user_id
客户端请求会自动带上:打开浏览器开发者工具(F12)→ Network → 刷新/get_cookie页面,查看请求头中的Cookie字段,会发现"user_id=123456"已自动携带。
错误示例:把敏感信息写进 Cookie
Cookie是存储在客户端的文本,即使设置了httpOnly,也可能被用户通过浏览器设置查看或修改。如果直接将密码、银行卡号等敏感信息存入Cookie,会存在极大安全风险。
错误代码(禁止这样写) :
@app.route('/set_sensitive_cookie')
def set_sensitive_cookie():
resp = make_response("错误示例:设置敏感信息Cookie")
# 严重错误:直接存储密码
resp.set_cookie("password", "12345678", max_age=3600)
return resp
调试技巧
- 浏览器查看Cookie:F12 → Application → Storage → Cookies,可查看当前网站的所有Cookie及属性
- 验证httpOnly有效性:在Console中输入
document.cookie,如果看不到httpOnly标记的Cookie,说明设置生效 - Postman测试:手动添加Cookie字段到请求头,模拟客户端携带场景
实际工作应用技巧
- 设置合理有效期:临时登录场景用session(关闭浏览器失效),长期免登录用较长max_age(如7天)
- 必加httpOnly和secure:httpOnly防XSS攻击,secure=True仅在HTTPS协议下传输Cookie
- 存储非敏感信息:如用户ID、主题偏好、购物车临时标识等,避免敏感数据
- 设置SameSite:SameSite=Lax或Strict,防止CSRF攻击(跨站请求伪造)
三、Session:把状态存回服务器
Cookie的痛点在于"状态存在客户端",存在被篡改风险。Session则反其道而行之:将核心状态数据存储在服务器,客户端只存一个唯一标识(SessionID) 。
类比理解:你去银行办理业务,工作人员给你一张叫号单(SessionID,存在Cookie里),你的个人信息、办理进度等核心数据都存在银行系统(服务器)里,你每次办理业务只需出示叫号单,工作人员通过单号调取你的数据。
核心流程:客户端首次请求 → 服务器创建Session(存储状态数据)→ 生成SessionID并通过Cookie发给客户端 → 后续请求客户端携带SessionID → 服务器通过ID查询Session数据。
正面示例
Flask内置了Session功能,默认基于Cookie存储SessionID,下面演示登录状态保持场景:
服务端(Python Flask) :
from flask import Flask, session, request, redirect, url_for
app = Flask(__name__)
# 关键:配置Session密钥(用于加密SessionID,必须设置,生产环境要复杂且保密)
app.secret_key = "dev_secret_key_123456" # 生产环境用os.urandom(24)生成随机密钥
# 模拟用户数据库
USER_DB = {
"admin": "admin123",
"user1": "user123"
}
# 登录接口:验证成功后创建Session
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
# 验证账号密码
if USER_DB.get(username) == password:
# 创建Session,存储用户信息(服务器端)
session['username'] = username
session['is_login'] = True
return redirect(url_for('index'))
else:
return "账号或密码错误"
# GET请求返回登录表单
return '''
<form method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<input type="submit" value="登录">
</form>
'''
# 首页:需要登录才能访问
@app.route('/')
def index():
# 验证Session状态
if session.get('is_login'):
return f"欢迎回来,{session['username']}!<a href='/logout'>退出登录</a>"
else:
return "请先<a href='/login'>登录</a>"
# 退出登录:删除Session
@app.route('/logout')
def logout():
# 清除Session中的登录状态
session.pop('username', None)
session.pop('is_login', None)
return "退出成功,<a href='/login'>重新登录</a>"
if __name__ == '__main__':
app.run(debug=True)
测试说明:
- 访问
http://127.0.0.1:5000,会被引导到登录页 - 输入正确账号(如admin)和密码(admin123),登录后进入首页
- 查看浏览器Cookie,会发现有一个名为"session"的字段(存储的是加密后的SessionID)
- 退出登录后,Session中的状态被清除,再次访问首页需要重新登录
错误示例:把 Session 存在本地文件
Flask默认将Session数据存储在服务器的本地文件中(开发环境),这种方式在生产环境存在严重问题:
- 分布式部署时,多台服务器之间的Session不共享,导致用户在A服务器登录后,访问B服务器仍需重新登录
- 本地文件读写效率低,高并发场景会成为瓶颈
错误配置(默认开发环境,生产禁用) :
# Flask默认Session存储方式(本地文件),无需手动配置,但生产环境必须修改
app.config['SESSION_TYPE'] = 'filesystem' # 默认值,生产禁用
正确做法:生产环境将Session存储在Redis、Memcached等分布式缓存中,确保多服务器共享。
调试技巧
- 查看SessionID:浏览器Cookie中的"session"字段就是加密后的SessionID
- 调试Session数据:开发环境可打印session对象,如
print(session),查看存储的状态 - Redis存储调试:如果用Redis存储,可通过
redis-cli keys "*session*"查看Session数据
实际工作应用技巧
- Session存储介质选择:开发环境用文件,测试/生产用Redis(高性能、支持分布式)
- 设置Session有效期:避免Session长期有效占用资源,如配置
PERMANENT_SESSION_LIFETIME = timedelta(hours=2) - 敏感数据加密:即使存在服务器,用户密码等敏感信息也需加密后再存入Session
- 防止Session固定攻击:用户登录成功后,重新生成一个新的SessionID(Flask已默认实现)
四、Token:适配移动端与分布式时代
Session虽然解决了Cookie的安全问题,但在移动端(APP、小程序)和分布式架构下仍有局限:移动端不一定支持Cookie,分布式环境需要共享Session存储(如Redis)。
Token应运而生:服务器生成一串加密的字符串(Token),客户端存储(如浏览器localStorage、APP本地缓存),每次请求时主动在请求头中携带。服务器通过解密Token验证身份,无需存储Token本身(如JWT)。
核心优势:无Cookie依赖、服务器无状态(无需存储Token)、天然支持分布式和移动端。
JWT 的基本结构(面试必问)
目前最流行的Token标准是JWT(JSON Web Token),本质是一串由点(.)分隔的字符串,分为三部分:头部(Header)、载荷(Payload)、签名(Signature) 。
# JWT示例(三部分用.分隔)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzE5NzQwMDAwLCJleHAiOjE3MTk3NDM2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header(头部) :指定加密算法和Token类型,Base64编码(可解密)
{ `` "alg": "HS256", // 加密算法(HMAC SHA256) `` "typ": "JWT" // Token类型 ``} - Payload(载荷) :存储核心信息(如用户ID、过期时间),Base64编码(可解密,不要存敏感信息)
{ `` "username": "admin", // 自定义信息(用户标识) `` "iat": 1719740000, // 签发时间(时间戳) `` "exp": 1719743600 // 过期时间(时间戳,这里是1小时) ``} - Signature(签名) :用Header指定的算法,结合服务器密钥对Header和Payload的编码结果进行加密,用于验证Token是否被篡改。签名无效则Token失效。
关键提醒:Payload是Base64编码(不是加密),任何人都能解密查看,所以绝对不能存密码、手机号等敏感信息!
正面示例:后端生成 Token
使用PyJWT库生成和验证JWT,先安装依赖:pip install pyjwt
服务端(Python Flask) :
from flask import Flask, request, jsonify
import jwt
import time
from datetime import datetime, timedelta
app = Flask(__name__)
# 关键:JWT密钥(生产环境必须保密,建议用环境变量存储)
SECRET_KEY = "dev_jwt_secret_key_123456" # 生产环境:os.getenv("JWT_SECRET")
# 模拟用户数据库
USER_DB = {
"admin": "admin123",
"user1": "user123"
}
# 1. 登录接口:验证成功后生成JWT
@app.route('/login_jwt', methods=['POST'])
def login_jwt():
data = request.get_json()
username = data.get('username')
password = data.get('password')
# 验证账号密码
if USER_DB.get(username) != password:
return jsonify({"code": 401, "msg": "账号或密码错误"})
# 生成JWT(设置过期时间1小时)
payload = {
"username": username,
"iat": datetime.utcnow(), # 签发时间(UTC时间)
"exp": datetime.utcnow() + timedelta(hours=1) # 过期时间
}
# 加密生成Token
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
return jsonify({
"code": 200,
"msg": "登录成功",
"data": {"token": token}
})
# 2. 需验证Token的接口(示例:获取用户信息)
@app.route('/get_user_info')
def get_user_info():
# 从请求头获取Token(约定格式:Authorization: Bearer <token>)
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({"code": 403, "msg": "请携带有效的Token"})
# 提取Token
token = auth_header.split(' ')[1]
try:
# 解密Token(自动验证过期时间和签名)
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
username = payload.get('username')
return jsonify({
"code": 200,
"msg": "成功",
"data": {"username": username, "role": "admin" if username == "admin" else "user"}
})
except jwt.ExpiredSignatureError:
return jsonify({"code": 403, "msg": "Token已过期,请重新登录"})
except jwt.InvalidTokenError:
return jsonify({"code": 403, "msg": "Token无效或已被篡改"})
if __name__ == '__main__':
app.run(debug=True)
测试流程(用Postman) :
- 发送POST请求到
http://127.0.0.1:5000/login_jwt,请求体JSON为:{ `` "username": "admin", `` "password": "admin123" ``}成功后会返回token - 发送GET请求到
http://127.0.0.1:5000/get_user_info,在请求头添加:Authorization: Bearer 刚才返回的token成功后会返回用户信息 - 如果Token过期或篡改,会返回403错误
错误示例:JWT 存过多敏感信息
很多开发者误以为JWT是加密的,就把敏感信息存进Payload,这是严重错误。因为Payload是Base64编码,可直接解密查看。
错误代码(禁止这样写) :
# 严重错误:Payload中存储密码
payload = {
"username": "admin",
"password": "admin123", # 绝对禁止!
"exp": datetime.utcnow() + timedelta(hours=1)
}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# 任何人拿到Token后,用Base64解密Payload就能看到密码!
调试技巧
- 在线解析JWT:访问 jwt.io/,粘贴Token可直接查看Header和Payload(验证是否泄露敏感信息)
- Token过期调试:开发时可将过期时间设短(如30秒),测试过期逻辑
- 请求头携带检查:F12→Network→查看请求头,确认Authorization字段是否正确携带
实际工作应用技巧
- 密钥管理:生产环境JWT密钥必须保密,通过环境变量或配置中心存储,避免硬编码
- 过期时间设计:采用"短Token+刷新Token"机制,如访问Token有效期2小时,刷新Token有效期7天,避免频繁登录
- 客户端存储:浏览器存localStorage(方便获取)或sessionStorage(关闭页面失效),APP存本地沙盒存储
- Token注销:由于服务器不存储Token,注销需客户端删除本地Token,或在服务器维护"黑名单"(如Redis存储已注销Token,有效期同Token过期时间)
五、项目实战:构建一个支持三种认证方式的简易系统
下面整合前面的知识,构建一个支持Cookie、Session、Token三种认证方式的用户中心系统,实现"登录-获取信息-退出"完整流程。
实战功能需求
- 提供三种登录接口:/login_cookie、/login_session、/login_token
- 提供统一的用户信息接口:/user_info(自动识别认证方式)
- 提供三种退出接口:/logout_cookie、/logout_session、/logout_token
样例代码:完整系统实现
from flask import Flask, request, jsonify, make_response, session, redirect, url_for
import jwt
from datetime import datetime, timedelta
import os
app = Flask(__name__)
# 配置
app.secret_key = os.urandom(24) # Session密钥(随机生成)
JWT_SECRET = os.getenv("JWT_SECRET", "prod_secret_123") # JWT密钥
USER_DB = {"admin": "admin123", "user1": "user123"} # 模拟数据库
# -------------------------- 公共工具函数 --------------------------
def verify_user(username, password):
"""验证账号密码"""
return USER_DB.get(username) == password
def get_user_info(username):
"""获取用户信息(模拟从数据库查询)"""
return {
"username": username,
"role": "admin" if username == "admin" else "user",
"avatar": "https://example.com/avatar.jpg"
}
# -------------------------- Cookie认证 --------------------------
@app.route('/login_cookie', methods=['POST'])
def login_cookie():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not verify_user(username, password):
return jsonify({"code": 401, "msg": "账号密码错误"})
# 设置Cookie
resp = jsonify({"code": 200, "msg": "Cookie登录成功"})
resp.set_cookie("username", username, max_age=3600, httpOnly=True, secure=False)
return resp
@app.route('/logout_cookie')
def logout_cookie():
# 删除Cookie
resp = jsonify({"code": 200, "msg": "Cookie退出成功"})
resp.delete_cookie("username")
return resp
# -------------------------- Session认证 --------------------------
@app.route('/login_session', methods=['POST'])
def login_session():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not verify_user(username, password):
return jsonify({"code": 401, "msg": "账号密码错误"})
# 设置Session
session['username'] = username
session['is_login'] = True
return jsonify({"code": 200, "msg": "Session登录成功"})
@app.route('/logout_session')
def logout_session():
# 清除Session
session.pop('username', None)
session.pop('is_login', None)
return jsonify({"code": 200, "msg": "Session退出成功"})
# -------------------------- Token认证 --------------------------
@app.route('/login_token', methods=['POST'])
def login_token():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not verify_user(username, password):
return jsonify({"code": 401, "msg": "账号密码错误"})
# 生成JWT
payload = {
"username": username,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=1)
}
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
return jsonify({"code": 200, "msg": "Token登录成功", "data": {"token": token}})
# -------------------------- 统一用户信息接口 --------------------------
@app.route('/user_info')
def user_info():
# 1. 尝试Token认证
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
try:
token = auth_header.split(' ')[1]
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
username = payload.get('username')
return jsonify({"code": 200, "data": get_user_info(username), "auth_type": "token"})
except:
pass # Token无效,尝试其他方式
# 2. 尝试Session认证
if session.get('is_login'):
username = session.get('username')
return jsonify({"code": 200, "data": get_user_info(username), "auth_type": "session"})
# 3. 尝试Cookie认证
username = request.cookies.get('username')
if username and username in USER_DB:
return jsonify({"code": 200, "data": get_user_info(username), "auth_type": "cookie"})
# 所有认证方式失败
return jsonify({"code": 403, "msg": "未登录,请先通过任意方式登录"})
if __name__ == '__main__':
app.run(debug=True)
测试验证:
- 用Postman通过Token登录后,访问/user_info,返回auth_type: "token"
- 通过Cookie登录后,访问/user_info,返回auth_type: "cookie"
- 退出后再次访问/user_info,返回403错误
六、高级技巧:如何选择适合自己的认证方式?
三种方式没有绝对的优劣,关键看业务场景。下面从6个维度给出选择建议:
| 场景维度 | Cookie | Session | Token(JWT) |
|---|---|---|---|
| 适用客户端 | 仅浏览器(支持Cookie) | 主要浏览器(依赖Cookie存SessionID) | 浏览器、APP、小程序、第三方接口(无Cookie依赖) |
| 分布式架构 | 支持(但需注意跨域Cookie问题) | 需共享存储(如Redis),略复杂 | 天然支持(服务器无状态),推荐 |
| 安全性 | 较低(客户端可修改,需httpOnly防护) | 较高(状态存服务器,仅传SessionID) | 中高(签名防篡改,但Payload可解密) |
| 开发成本 | 低(浏览器自动携带) | 中(需配置存储介质) | 中(需手动处理Token携带和验证) |
| 注销难度 | 易(服务器删除Cookie) | 易(服务器删除Session) | 较难(需客户端删除或维护黑名单) |
| 推荐场景 | 简单网站、非敏感信息存储(如主题偏好) | 传统Web网站、需要强状态管理(如电商购物车) | 移动端APP、分布式系统、第三方API接口 |
实战建议:目前主流方案是"Token为主,Session为辅"——移动端和分布式服务用Token,传统Web后台用Session,简单场景用Cookie存储非敏感信息。
七、拓展原理:为什么分布式架构更偏爱 Token?
分布式架构的核心是"多台服务器协同工作,对外提供统一服务",这就对状态保持提出了"跨服务器共享状态"的要求。Session和Token在这一点上的差异是关键:
- Session的瓶颈:状态依赖存储Session的核心状态数据存在服务器端,如果用本地文件存储,多台服务器之间的Session无法共享——用户在服务器A登录后,请求被负载均衡分发到服务器B,B上没有该用户的Session,就会要求重新登录。虽然可以通过Redis等分布式缓存解决Session共享问题,但会带来额外的架构复杂度:需要维护Redis集群、处理缓存穿透/击穿问题、增加网络开销(服务器每次验证都要查Redis)。
- Token的优势:服务器无状态Token(如JWT)的核心是"服务器无需存储Token信息"——Token本身包含了用户标识和过期时间,服务器只需通过密钥解密和验证签名,就能确认用户身份。这种无状态特性完美适配分布式架构:无需共享存储,减少架构复杂度
- 多台服务器可独立验证Token,无需通信,提高并发性能
- 支持水平扩展,新增服务器无需额外配置
简单说:分布式架构追求"去中心化",而Token的无状态特性正好契合这一理念,Session则需要"中心化存储"作为支撑,因此Token更受青睐。
八、总结
回到最初的问题:Cookies、Session、Token到底是什么关系?其实它们都是为了解决HTTP无状态问题的技术方案,只是从不同角度实现:
- Cookie:客户端存储状态,简单但安全性低,依赖浏览器
- Session:服务器存储状态,安全性高但依赖共享存储,适配传统Web
- Token:客户端存储加密令牌,服务器无状态,适配移动端和分布式
最后用一张图总结核心区别和适用场景,方便大家收藏记忆:
关键takeaway:没有最好的方案,只有最适合的方案。根据客户端类型、架构模式、安全要求选择认证方式,才是高效开发的核心。
如果这篇文章帮你理清了三者的关系,欢迎点赞收藏~ 有问题或不同见解,评论区见!