不知道你有没有这种经历:用 Flask 写了好几个页面,表单提交、数据库查询都顺畅了,但一碰到登录、cookie、token、headers 这些词就开始发懵。浏览器里每个请求都带着一堆看不见的“附加信息”,服务器又是怎么知道你是谁的?哪个数据该放 cookie,哪个该放 headers,token 又是干什么的?这篇文章就用 Flask 把这三样东西一次讲明白,不讲虚的,直接上手能看到效果。
这个内容适合刚学完 Flask 路由和模板、准备接触用户登录和接口鉴权的同学,也适合后端转前端、或者用 requests 写脚本时总被登录态卡住的人。读完你能搞清楚 cookie、token、headers 各自的职责,能在 Flask 里熟练地设置和读取它们,还能用 JWT 实现一个真正能用的登录鉴权接口。
1. 先搞清楚 cookie、session、token 到底在解决什么问题
1.1 HTTP 是无状态的,这到底意味着什么
很多人学 Flask 学到 session 和 cookie 时,第一个困惑是:为什么不能直接把“当前登录用户”存在服务器上一个全局变量里?比如 current_user = '张三',下次请求来了直接判断不就行了吗?
这里必须先理解 HTTP 协议的特性。HTTP 是无状态的协议,意思是每个请求都是独立的,服务器处理完一个请求之后,不会主动“记住”这个请求是谁发来的。浏览器请求一个页面,服务器返回 HTML;浏览器再请求这个页面上的图片,服务器照样不认识你。哪怕你是连续点击三次同一个按钮,三次请求之间,服务器层面没有任何天然联系。
这个设计初衷是为了简单。早期 Web 就是静态文档浏览,没有登录、购物车这些概念。但网站慢慢变成应用之后,问题就来了:服务器需要知道“这个请求是不是来自刚刚登录的那个用户”。于是大家开始在各种层面加“状态标记”。
用生活里的事来类比:HTTP 请求就像一个每次进店都假装第一次来的顾客。服务员每次都要问“您好,第一次来吗?请问要什么?”。如果你常去一家店,你肯定希望能有个方式让服务员认出你,省得每次都重新自我介绍。
而 cookie、session、token 就是解决这个“被认出来”问题的三种方案。
1.2 Cookie:服务器贴在你浏览器上的便利贴
Cookie 是第一种方案。服务器在响应里通过 Set-Cookie 这个响应头,把一小段文本交给浏览器,浏览器收到后存到本地。以后浏览器再访问同一域名下的页面,就会自动把这小段文本放进请求头里的 Cookie 字段,随身携带。
Cookie 的特点是“站在客户端”。数据存在用户浏览器里,服务器不保存任何东西。每次请求自动带上,不需要前端写代码手动拼接。
典型使用场景也非常朴素:
- 记住登录状态(服务器只验证 cookie 里的凭证)
- 记住用户偏好(语言、主题、上次浏览位置)
- 追踪行为(统计访问、A/B 测试分组)
但 Cookie 有明显的限制。首先是体积,一个 Cookie 通常只能放 4KB 左右的数据,放不了多少东西。其次因为存在用户本地,用户可以删、能改,把里面的值改了再回传你也拦不住。最重要的是它每次请求都自动带,所以里面不应该放敏感信息,不然会有隐私风险。
1.3 Session:存在服务器那边的“档案袋”
后来大家发现,cookie 能存的东西太少,而且暴露在客户端不安全,于是有了 session。Session 的思路是:数据存在服务器上,只给浏览器一个“档案编号”。
具体流程是:用户登录成功后,服务器生成一个唯一的 session ID,把它通过 Set-Cookie 发给浏览器,同时把用户信息存到服务器内存、文件或者数据库里。浏览器下次请求,带上 session ID,服务器拿着这个编号去自己的“档案柜”里找对应的用户数据。
Session 解决了 cookie 不安全的问题,因为用户真正的那份资料在服务器侧,浏览器只拿编号。但它引入了新的问题:服务器要维护状态。如果服务部署了多台机器,用户请求第一次落到 A 机器,第二次落到 B 机器,B 机器里没有他的 session,这用户就被当成“未登录”了,这就是分布式场景下最头疼的 session 同步问题。
1.4 Token:把身份信息加密后交给客户端
Token 的思路和 session 完全不同。Session 是“服务器保存档案”,Token 是“服务器把身份信息签字加密之后,交给客户端保存,下次客户端原样带回来”。
服务器不保存 token,只保存验签用的密钥。用户拿到 token,以后每次请求时把 token 放到请求头里,服务器验一下签名没问题、没过期,就认这个请求是哪个用户的。
这么说可能有点抽象。我做技术分享的时候,经常拿健身房手环来比喻:
- cookie 就像健身房发给你的一张纸条,写着你的会员名,你贴在脑门上走进去,前台一看名字就知道你是谁。但纸条怕水、怕折、容易丢,而且你如果随便改纸上的字,前台也拦不住。
- session 是健身房办了一张带编号的储物卡,你人到了刷卡,前台去抽屉里拿你的资料核对,数据都在健身房这边,你手里只有一张卡。问题是如果这家健身房开了五家分店,你的卡在 A 店刷过,去 B 店想刷,B 店抽屉里可能没有你的档案。
- token 是一张防伪塑封卡,上面印着你的姓名、会员等级,还带健身房钢印。你去哪家分店都不用刷卡机,前台用放大镜看一眼钢印是真的、日期没过期,就直接放行。
这三种方式后面在 Flask 里都会讲到,先记这个印象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Flask 里操作 Cookie 的实战细节
2.1 设置和读取 Cookie:一个最小可跑的示例
在 Flask 里设置 cookie 使用的是响应对象。Flask 的视图函数返回普通字符串,这个 return 背后其实已经创建了一个 Response 对象。如果要操作 cookie,我们需要拿到这个 Response 对象。
最直接的办法是用 make_response:
python复制from flask import Flask, request, make_response
app = Flask(__name__)
@app.route('/set-cookie')
def set_cookie():
resp = make_response('cookie 已经设置')
resp.set_cookie('username', 'zhangsan', max_age=3600)
return resp
if __name__ == '__main__':
app.run(debug=True)
运行后打开浏览器,访问 /set-cookie,再按 F12 打开开发者工具,切到 Application 面板,在 Cookies 那栏就能看到一个 username=zhangsan 的记录,有效期 3600 秒,也就是一小时。
读取 cookie 更简单,从 request.cookies 里取就行:
python复制@app.route('/get-cookie')
def get_cookie():
username = request.cookies.get('username')
return '读取到的 cookie 是:%s' % username
这里有个值得注意的细节:request.cookies 是一个类似字典的对象,直接打印不会报错,但取不存在的 key 时会返回 None。所以实际项目里不要用 request.cookies['username'] 这种写法,容易崩,用 .get() 更稳妥,可以给默认值。
2.2 Cookie 的关键参数不只是过期时间
set_cookie 看起来就是个简单方法,但里面有几个参数在实际项目里非常重要,新手特别容易忽略。
第一个是 path,表示 cookie 在哪些路径下生效。默认是 /,也就是整个站点都会带上。如果一个接口只想让 /admin 路径下携带 cookie,可以指定 path='/admin'。因为浏览器每次请求都要自动塞 cookie,路径设置得越具体,请求头就越小,多余的流量就越少。
第二个是 domain,控制 cookie 在哪个域名下生效。比如你的登录接口在 login.example.com,而主页是 www.example.com,如果 cookie 不设置 domain='.example.com',浏览器可能不会把 cookie 发给 www 这个子域。跨子域共享登录态是后端经常踩的坑。
第三个是 httponly 和 samesite。这两个偏安全,后面专门讲。
第四个是 secure,如果设置为 True,浏览器只会在 HTTPS 连接下携带这个 cookie。本地 HTTP 调试时开了 secure,浏览器会“无视”这个 cookie,那排查起来心态很容易崩。
实际项目里我一般是这么设置的:
python复制resp.set_cookie(
'session_id',
'a1b2c3d4',
max_age=86400,
httponly=True,
samesite='Lax',
secure=app.config.get('COOKIE_SECURE', False)
)
COOKIE_SECURE 放到配置文件里,线上环境开 True,本地调试开 False,避免本地跑的时候 cookie 发不出去。
2.3 删除 Cookie:不是删掉,而是让它过期
删除 cookie 有两个办法。一个是用 delete_cookie:
python复制@app.route('/logout')
def logout():
resp = make_response('已退出登录')
resp.delete_cookie('username')
return resp
另一个办法是设置 max_age=0。原理上,cookie 没有服务端主动删除的机制,浏览器判断一个 cookie 失效的唯一标准是过期时间。delete_cookie 做的事就是把过期时间设成过去的时间,让浏览器立即丢弃它。
这里有一个坑:delete_cookie 必须和当初 set_cookie 时使用相同的 path 和 domain。否则浏览器找不到对应的 cookie,删除操作就是静默失败的,下次刷新页面发现 cookie 还在。
2.4 前端经常遇到的几个 Cookie 坑
先说“cookie 中文”。set_cookie 直接放中文,浏览器会报 InvalidHeader 之类的错误。RFC 6265 规定 cookie 值建议使用 ASCII 字符,所以中文必须编码。最简单的做法是用 urllib.parse.quote 转成 URL 编码:
python复制from urllib.parse import quote
resp.set_cookie('nickname', quote('张三'))
读取的时候再 unquote 解回来。
再说开发调试时看网络面板经常出现的一句话:provisional headers are shown。这句话的意思是浏览器正在显示一个“暂时性”的请求头,通常出现在请求还没真正发出去、或者请求被浏览器拦截的情况下。常见原因是 cookie 或者 CORS 配置导致请求被 preflight 卡住、Chrome 扩展拦截了请求、或者请求超时被取消。看到这行提示,先别急着改后端,按 F5 刷新,或者换无痕窗口再试一次,排除浏览器缓存和扩展的干扰。如果换环境还是不行,再检查接口的 CORS 响应头配置。
3. Headers:请求和响应的“信封”上写了什么
3.1 为什么 headers 值得单独拎出来讲
很多 Flask 新手习惯了处理 body 里的数据,看到 headers 里那一堆键值对,总觉得那是浏览器和框架自动处理的东西,跟自己没关系。但实际上,headers 才是 HTTP 请求里信息最密集的区域。Cookie 是 headers 里的一行,token 也是放在 headers 里的,Content-Type、User-Agent、Accept、Referer、Authorization 全都是 headers 的一部分。
把 HTTP 请求想象成寄快递。headers 是快递单上的发件人、收件人、物品类型、保价声明;body 是包装盒里的实际物品;method 是你选择哪种快递服务(普通、加急、货到付款)。如果只看 body,就像只知道盒子里装了什么,不知道快递该往哪儿寄、值不值得保价。
3.2 常见的请求头和响应头速查
开发中我经常跟人讲,headers 不需要背,但至少要能认出几个高频字段。下面这个表是实际开发中见到最多的:
| 类型 | 字段名 | 作用 | 常见示例 |
|---|---|---|---|
| 请求头 | Host | 请求的目标域名 | example.com |
| 请求头 | User-Agent | 客户端类型和版本 | Mozilla/5.0 ... |
| 请求头 | Authorization | 携带凭证,如 token | Bearer eyJhbGci... |
| 请求头 | Content-Type | body 的数据格式 | application/json |
| 请求头 | Accept | 客户端希望返回的格式 | application/json |
| 请求头 | Cookie | 浏览器自动携带的 cookie 串 | username=zhangsan |
| 响应头 | Content-Type | 返回的数据格式 | application/json |
| 响应头 | Set-Cookie | 告诉浏览器要存的 cookie | username=zhangsan; Max-Age=3600 |
| 响应头 | Access-Control-Allow-Origin | 跨域允许的来源 | * |
| 响应头 | Cache-Control | 缓存策略 | no-cache |
注意 Authorization 和 Cookie 的区别。两者都是携带凭证,但 Authorization 通常是前端代码手动设置的,而 Cookie 是浏览器自动带的。现在前后端分离项目里,token 一般放 Authorization 而不是 cookie,就是为了避免浏览器自动携带导致 CSRF 攻击风险。
3.3 Flask 中读取请求头的两种方式
Flask 中读取请求头用的是 request.headers,它是个类似字典的对象,并且大小写不敏感。也就是说 request.headers.get('User-Agent') 和 request.headers.get('user-agent') 都能取到同一个值。
python复制@app.route('/user-agent')
def read_ua():
ua = request.headers.get('User-Agent')
return '你的浏览器环境是:%s' % ua
访问这个接口,就能看到一串完整的浏览器标识。这个接口在实际工作中很有用,比如做移动端和 PC 端的不同页面适配。
如果要读取自定义请求头,比如前端想传一个 X-Request-Id,用法一样:
python复制@app.route('/trace')
def trace():
request_id = request.headers.get('X-Request-Id', 'unknown')
return '当前请求的追踪ID:%s' % request_id
注意,跨域环境下自定义请求头会触发 CORS 预检,后端需要配置 Access-Control-Allow-Headers 把自定义头名加进去,不然前端请求会被浏览器拦截。
3.4 Flask 中设置响应头
给响应加 headers 最灵活的方式是 make_response 之后手动配置:
python复制@app.route('/custom-header')
def custom_header():
resp = make_response('这是一段自定义响应')
resp.headers['X-Server-Name'] = 'flask-demo'
resp.headers['Cache-Control'] = 'no-cache'
return resp
用浏览器访问接口,开发者工具的 Network 面板里选择该请求,看 Response Headers 部分,能看到这两行自定义响应头。
如果你用的是 jsonify 返回 JSON,也能通过二次加工加上响应头:
python复制from flask import jsonify
@app.route('/api/user')
def api_user():
resp = jsonify({'username': 'zhangsan'})
resp.headers['X-API-Version'] = '1.2'
return resp
这种能力在做 API 版本标识、调试信息透出、统一响应头注入的时候非常有用。
3.5 一个实际场景:从 headers 里取 token 做接口鉴权
来一个能串起本章知识的实战场景。假设系统要求前端调接口时必须带 Authorization: Bearer <token>,后端每个接口都要校验这个 token 是否存在。
python复制from functools import wraps
from flask import request, jsonify
def token_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'code': 401, 'msg': '未携带有效的凭证'}), 401
return f(*args, **kwargs)
return wrapper
@app.route('/api/order')
@token_required
def get_order():
return jsonify({'code': 0, 'data': {'order_id': 12345}})
这里 startswith('Bearer ') 的判断很关键。直接用 split() 拆可能因为前导空格拿到空值,而 startswith 可以避免 token 串开头有意外空白时误判。当然这只是“有没有”的拦截,真正要验证 token 是真是假、有没有过期,还要用第 4 章的 JWT 方案。
4. 在 Flask 中实现基于 JWT 的 Token 鉴权
4.1 JWT 的结构:三段字符串各自的作用
刚才讲的 token 是理念层面的,具体实现最流行的是 JWT(JSON Web Token)。很多报错信息里都有 token exchange failed 之类的字眼,其实背后就是 JWT 在不同服务之间传递时出了问题。
一个 JWT 长这样:
text复制eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDAwMDAwMDB9.signature
中间用两个点分割成三段:
第一段是 Header,声明签名算法,一般是 alg: HS256。这段虽然是 base64 编码的,但任何人都能解码,所以里面不要放敏感信息。第二段是 Payload,存放业务数据,比如 user_id、username、exp(过期时间)。同样只是 base64 编码,不是加密,任何人拿到都能看到明文内容,所以密码这类数据绝对不能放进去。第三段是 Signature(签名),用服务端保存的密钥把前两段内容算出来的签名。只要密钥不泄露,别人改动了前两段任意一个字符,签名验证就会失败。
所以 JWT 本质上不是“加密”,而是“防篡改 + 可验证身份”。很多初学者以为 JWT 是加密的,把用户手机号、邮箱直接扔进去,这是很大的误解。
4.2 生成 Token:安装 PyJWT 并实现登录接口
在 Flask 中使用 JWT,最常用的库是 PyJWT,安装一行命令:
bash复制pip install pyjwt
然后封装两个工具函数,一个是生成 token,一个是校验 token:
python复制import jwt
import datetime
from flask import current_app
def create_token(user_id):
payload = {
'user_id': user_id,
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=2)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
return token
def verify_token(token):
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
return payload.get('user_id')
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
注意 exp 字段一定要设置,不然你的 token 就永远不会过期。而 jwt.decode 时 PyJWT 会自动检查 exp,过期会抛出 ExpiredSignatureError。
登录接口的写法就非常清晰了:
python复制from flask import request, jsonify
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if username == 'admin' and password == '123456':
token = create_token(username)
return jsonify({'code': 0, 'token': token})
return jsonify({'code': 1, 'msg': '用户名或密码错误'}), 401
用 requests 测试一下:
python复制import requests
resp = requests.post('http://127.0.0.1:5000/login', json={
'username': 'admin',
'password': '123456'
})
print(resp.json())
正常会打印出 {'code': 0, 'token': 'eyJhbGciOi...'}。这时候你把这个 token 复制到 jwt.io 去解码,网站会把 Header 和 Payload 的明文内容显示出来,但你必须有密钥才能验证签名,这正好印证了“可读但不可篡改”的特点。
4.3 校验 Token:受保护接口的完整实现
有了 token 生成之后,我们来写一个真正需要登录才能访问的接口:
python复制@app.route('/profile', methods=['GET'])
def profile():
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'code': 401, 'msg': '缺少token'}), 401
token = auth_header.split(' ', 1)[1]
user_id = verify_token(token)
if not user_id:
return jsonify({'code': 401, 'msg': 'token无效或已过期'}), 401
return jsonify({'code': 0, 'data': {'user_id': user_id}})
这里有几个实际项目中的小细节:
- 用
split(' ', 1)而不是不加参数的split(),是为了防止 token 本身包含多个空格导致切片错位。 verify_token返回None时统一返回 401,不会因为 token 过期或者非法给前端不同提示,减少信息泄露。- 校验 token 的操作应该抽成装饰器,否则每个接口都要写一遍同样代码,改动成本高,还会出现复制粘贴漏改的情况。
把上一章的装饰器升级一下,可以把 token 里的 user_id 透传到视图里:
python复制from functools import wraps
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'code': 401, 'msg': '缺少token'}), 401
token = auth_header.split(' ', 1)[1]
user_id = verify_token(token)
if not user_id:
return jsonify({'code': 401, 'msg': 'token无效或已过期'}), 401
kwargs['user_id'] = user_id
return f(*args, **kwargs)
return wrapper
@app.route('/api/order', methods=['GET'])
@login_required
def get_order(user_id):
return jsonify({'code': 0, 'data': {'order_id': 67890, 'user_id': user_id}})
4.4 Token 失效与续签:别让用户每两小时登录一次
实际开发中,token 过期时间不能设得太长,否则安全风险高;也不能设得太短,否则用户体验差。折中方案是双 token 机制:
- access_token:短期有效,比如 30 分钟,用于访问接口。
- refresh_token:长期有效,比如 7 天,只用于刷新 access_token。
当 access_token 过期,前端用 refresh_token 调一个刷新接口,换取新的 access_token。refresh_token 过期,才要求用户重新登录。
简化版实现:
python复制def create_access_token(user_id):
payload = {
'user_id': user_id,
'type': 'access',
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30)
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def create_refresh_token(user_id):
payload = {
'user_id': user_id,
'type': 'refresh',
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7)
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
@app.route('/refresh', methods=['POST'])
def refresh_token():
data = request.get_json()
refresh_token = data.get('refresh_token')
user_id = verify_token(refresh_token)
if not user_id:
return jsonify({'code': 401, 'msg': '刷新凭证已失效,请重新登录'}), 401
new_access_token = create_access_token(user_id)
return jsonify({'code': 0, 'access_token': new_access_token})
刷新接口里注意区分 token 的类型。最好在 payload 里带一个 type 字段,因为理论上 access_token 也是 JWT,拿着 access_token 去换新 token 就不合理了。实践里我还会在刷新的时候检查 payload['type'] == 'refresh',否则就拒绝。
Token 失效还有另一种情况:用户主动退出登录或者被管理员封号。JWT 本身无状态,服务端没法直接让它“失忆”,所以要么引入黑名单机制,要么把 token 版本号存在数据库里做校验。如果只是个人项目,最省事的做法是把 token 有效期设短一点,接受一个较小的风险窗口。
5. 三者在真实项目中的配合与常见排查
5.1 Cookie 还是 Token?该放哪、怎么选
选择困难其实没那么复杂,先看项目的结构。
传统服务端渲染的项目(后端用 Jinja2 模板直接渲染页面),登录后把 session ID 写到 cookie 里,后续请求浏览器自动带 cookie,后端根据 session ID 查出用户。这个模式实现简单、无感刷新,适合内部系统或不太需要大量 API 复用的网站。
前后端分离的项目(前端是 Vue、React 或小程序),后端主要提供 JSON API,这时 token 方案更顺手。前端登录后拿到 token,存在内存或 localStorage 里,每次请求通过 Authorization 请求头带上。这样天然避免了 CSRF 问题,跨端复用也方便,同一个 API 可以同时服务 Web 端、App 端、小程序端。
一个常见的误区是:既然 token 在 headers 里,cookie 是不是没用了?不是。cookie 仍然适合存储非敏感的、需要浏览器自动携带的标识信息,比如埋点 ID、语言偏好、匿名购物车编号。而 token 更适合做身份凭证。两者完全可以共存,各干各的活。
我做一个对比表方便决策:
| 维度 | Cookie 方案 | Token 方案 |
|---|---|---|
| 存储位置 | 浏览器本地 | 客户端(内存、localStorage) |
| 生命周期 | 由服务端通过 Max-Age 控制 | 由 token 中的 exp 控制 |
| 跨域支持 | 设置较麻烦,需要处理 CORS | 灵活,任意域名可带 |
| CSRF 风险 | 高风险,需要防护 | 低,手动设置头不自动携带 |
| 适合场景 | 传统网页、服务端渲染 | 前后端分离、API 服务、移动端 |
5.2 登录报错的排查路径
实际开发里,很多人遇到登录接口报错时都会一脸懵。最典型的报错就是那种 token exchange failed,虽然各种框架的报错文字不一样,但核心都是“拿 token 换用户身份失败了”。
遇到这种问题,F12 打开网络面板,按下面的顺序排查:
- 看状态码。401 表示客户端凭证不对,403 表示服务器拒绝了这个操作,404 则是路径错了。
- 看响应体里的具体错误文案,很多登录服务会把真正的原因放在响应 JSON 里,而不只是状态码。
- 看请求的 headers 是否真的带上了目标服务器期望的凭证(Authorization、Content-Type 等)。
- 注意 body 格式。很多 token 接口要求
application/x-www-form-urlencoded,但前端按application/json发给它,服务端解析不出来,就会一路报错。
拿我之前遇到过的情况举例:后端同事说“登录接口报 token endpoint returned status 403”,一查,是某服务对特定区域来源做了访问限制,这是服务方的业务策略,不该试图绕过。正确做法是遵循服务的官方支持渠道,比如更换受支持的网络出口或咨询服务方。这里提醒一下,不要为了绕过这类限制去使用不合规工具,老老实实按照服务条款操作。
在具体工具层面,如果在服务器上用 curl 测登录接口,命令可以这样写:
bash复制curl -X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"123456"}'
用 curl 测完,可以和浏览器里的请求作对比,很快能定位是不是前端代码漏传了参数。
5.3 安全清单:Cookie 和 Token 的防御要点
讲完怎么用,必须讲怎么安全地用。网上泄露用户数据的案例很多,很多都是基础配置没做对。
Cookie 的防御要点:
HttpOnly=True:让 JavaScript 无法通过document.cookie读取,防止 XSS 攻击直接偷走登录凭证。SameSite=Lax或Strict:防止浏览器在跨站请求时自动带 cookie,降低 CSRF 风险。Secure=True:明确只在 HTTPS 下传输,避免明文网络中被截获。- 不要在 cookie 里放明文用户信息,比如
username=zhangsan这种,别人一眼就知道是谁,最好只放一个无意义的 session ID。
Token 的防御要点:
- 不要把 token 放在 URL 里。URL 会被浏览器历史、代理日志、服务器访问日志记下来,token 会因此泄露。
- 不要把 token 放在 localStorage 里。localStorage 没有隔离机制,任何一个 XSS 漏洞都能把它读走。更推荐放内存里,或者使用 HttpOnly cookie 承载 refresh_token。
- 设置合理的过期时间。长期 token 一旦泄露,危害窗口很大。
- 签名密钥要足够随机,不要用
'secret'这种。用环境变量配置密钥,并定期更换。
5.4 Flask 项目目录结构建议
最后给一个后端后台服务常用的项目结构,避免所有代码堆在一个 app.py 里:
text复制flask_demo/
├── app.py # 应用入口,创建 app、注册蓝图
├── config.py # 配置项,SECRET_KEY、数据库地址等
├── requirements.txt # 依赖列表
├── extensions.py # db、jwt 等扩展实例
├── models/ # 数据模型
│ └── user.py
├── views/ # 视图函数和蓝图
│ ├── auth.py # 登录、刷新、退出
│ └── user.py # 用户信息接口
├── utils/ # 工具函数
│ ├── jwt_utils.py # create_token、verify_token 封装
│ └── http_utils.py # 统一响应格式
├── static/ # 静态资源
└── templates/ # 模板文件
utils/jwt_utils.py 里放上边写的 create_token 和 verify_token,views/auth.py 里写登录和刷新接口,views/user.py 里写受保护的接口。这样每个文件职责单一,后期加需求也不容易打架。
如果用 Docker 部署,一个最小的 Dockerfile 也不复杂,核心步骤是拉取 Python 基础镜像、安装 requirements、启动 gunicorn:
dockerfile复制FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]
然后 docker build -t flask_demo .,再 docker run -p 5000:5000 flask_demo 就能跑起来。这里注意 app:app 指的是 app.py 文件里的变量 app,如果入口文件改了个名,这行也要改,否则容器启动日志里会直接报告找不到模块。
回到最初的问题,其实 cookie、headers、token 并不神秘。HTTP 是个无状态协议,但无状态不意味着没法记住人,只是需要把这些“记忆”放在请求的某个位置。Cookie 和 Authorization 头是两种主要的位置,Session 和 Token 是两种主要的数据组织方式。Flask 里该设置响应的地方就设置,该读取请求的地方就读取,逻辑理顺了,写鉴权接口就不会再发怵。
我个人实际操作中比较推荐的做法是:先在自己的项目里写一遍这三个流程,从设置 cookie、读取请求头、到生成和校验 JWT,然后把它们简化成一个可复用的登录装饰器。以后每次需要受保护接口,就加一个装饰器,省心不少。如果你刚开始上手,建议先用 curl 或者 Postman 手动调一遍登录接口,看看 cookie 和 token 分别出现在哪个位置,这个过程比看十篇文档都有用。
