前言
先把一个容易误解的前提说清楚:Session 与 Cookie 不是 PHP 8.0 才有的能力。Session 机制早在 PHP 4 时代就已经是内置功能,setcookie()更是从 PHP 3 就存在。标题里的 8.0 应当理解为"在 PHP 8.0 环境下怎么写",而不是"8.0 引入的新特性"。与本文直接相关的、确实属于较新版本的改动是:setcookie()与session_set_cookie_params()自 PHP 7.3 起支持数组形式的选项,从此设置SameSite这类参数不需要再去拼path、domain这些位置参数;而 PHP 8.0 带来的命名参数、严格类型等语法,让这些调用的可读性明显变好。
症状层面也有典型场景:登录后刷新页面就掉线;登录成功但把 Session ID 直接放在 URL 里被爬虫抓到;跨站请求时 Cookie 莫名不发送;session_start()报 "headers already sent";把数据库连接对象塞进$_SESSION后整个会话读取失败。
本文按"原理 → Cookie → Session → 完整可运行示例 → 坑点"的顺序,给出一套在 PHP 8.0 环境里可靠、安全的 Session 与 Cookie 管理办法。
一、原理:一个在服务端,一个在客户端
两者的分工非常清晰:
- Cookie是存在客户端的小段键值数据。服务端通过响应头
Set-Cookie下发,浏览器在之后的同域请求里通过请求头Cookie带回来,PHP 把它解析进$_COOKIE超全局数组。 - Session是存在服务端的数据(默认存成文件),客户端只持有一个标识符。这个标识符通常经由 Cookie 传递,Cookie 名由
session.name决定,默认是PHPSESSID。
由此推导出三条铁律:
- Cookie 的内容不可信。用户随时可以改自己浏览器里的 Cookie,所以权限、金额、用户 ID 这类信息只能放服务端。
- 下发 Cookie 就是发响应头。
setcookie()、session_start()都必须在任何输出之前调用,否则报 "headers already sent"。 - Session 的凭证等价于密码。拿到 Session ID 就等于拿到登录态,所以它必须是不可预测的、并且要防范会话固定攻击(session fixation)。
常用属性的取值建议:
| 属性 | 作用 | 建议 |
|---|---|---|
expires | 绝对过期时间戳 | 会话级 Cookie 可不设,让浏览器关闭即失效 |
path | 生效路径 | 通常设为/ |
domain | 生效域名 | 留空表示当前域;跨子域共享才显式设置 |
secure | 仅经 HTTPS 传输 | 生产环境一律开启 |
httponly | 禁止 JavaScript 读取 | 一律开启,能挡掉大量 XSS 窃取 |
samesite | 是否随跨站请求发送 | 一般Lax;纯跨站场景才用None |
二、Cookie:用数组选项写,别再拼位置参数
PHP 7.3 起,setcookie()的第三个参数既可以是旧的路径字符串,也可以是一个选项数组。数组形式的好处是每个参数都叫得出名字,尤其是samesite在旧的位置参数里根本表达不了。
<?php declare(strict_types=1); // 写 Cookie:有效期 30 天 setcookie('theme', 'dark', [ 'expires' => time() + 60 * 60 * 24 * 30, 'path' => '/', 'secure' => true, // 需要 HTTPS,本地调试可临时置 false 'httponly' => true, 'samesite' => 'Lax', ]); // 读 Cookie:始终当作不可信输入处理 $theme = $_COOKIE['theme'] ?? 'light'; if (!in_array($theme, ['light', 'dark'], true)) { $theme = 'light'; } // 删除 Cookie:必须用与写入时相同的 path / domain,并让过期时间成为过去 setcookie('theme', '', [ 'expires' => time() - 3600, 'path' => '/', 'httponly' => true, ]);两个容易忽略的细节:setcookie()会对值做 URL 编码,读取时 PHP 自动解码,所以不要自己再编码一次(不希望编码时用setrawcookie());删除时必须复用写入时的path与domain,否则浏览器会认为这是两个不同的 Cookie,旧的删不掉。
三、Session:开启时的选项决定安全性
session_start()接受一个选项数组,这些选项会覆盖php.ini里的同名配置。把关键项写在代码里,比依赖服务器配置更可靠——尤其是换机器部署的时候。
<?php declare(strict_types=1); session_start([ 'use_strict_mode' => 1, // 拒绝客户端伪造的、服务端没发过的会话 ID 'cookie_httponly' => 1, 'cookie_secure' => 1, // 生产环境开启 'cookie_samesite' => 'Lax', 'gc_maxlifetime' => 1800, // 服务端会话数据 30 分钟过期 ]); // 写入会话 $_SESSION['uid'] = 42; $_SESSION['name'] = 'alice'; // 登录成功那一刻:换一个新的会话 ID,防止会话固定攻击 session_regenerate_id(true); // 参数 true 表示同时删除旧会话文件 // 登出:清数据、毁会话、删 Cookie,三步都做 $_SESSION = []; if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie(session_name(), '', [ 'expires' => time() - 42000, 'path' => $params['path'], 'domain' => $params['domain'], 'secure' => $params['secure'], 'httponly' => $params['httponly'], 'samesite' => $params['samesite'] ?? 'Lax', ]); } session_destroy();其中use_strict_mode值得单独强调:它为 1 时,PHP 会拒绝任何"服务端没有生成过"的会话 ID,从根源上堵住会话固定攻击。为 0 时(不少旧配置的默认值就是 0),攻击者可以先构造一个已知的会话 ID 诱导受害者使用,再拿着同一个 ID 冒充受害者。
session_regenerate_id(true)的调用时机是权限发生变化时:登录成功、退出登录、提权。日常页面请求不需要反复调用。
四、完整可运行示例:三个脚本 + 命令行验证
下面三个脚本需要PHP 8.0 或更高,放在同一个目录,用php -S 127.0.0.1:8000启动内置服务器即可运行。为了让示例聚焦在会话与 Cookie 本身,输出全部是纯文本。
login.php:
<?php declare(strict_types=1); session_start([ 'use_strict_mode' => 1, 'cookie_httponly' => 1, 'cookie_samesite' => 'Lax', ]); // 演示用的账号表:真实项目应查数据库 $users = [ 'alice' => password_hash('secret', PASSWORD_DEFAULT), ]; $user = $_GET['user'] ?? ''; $pass = $_GET['pass'] ?? ''; if ($user === '' || $pass === '') { http_response_code(400); echo "缺少参数: 请带 user 与 pass 访问\n"; exit; } if (!isset($users[$user]) || !password_verify($pass, $users[$user])) { http_response_code(401); echo "账号或密码错误\n"; exit; } // 关键一步:登录成功即更换会话 ID session_regenerate_id(true); $_SESSION['uid'] = 1001; $_SESSION['name'] = $user; // 顺带写一个非敏感偏好到 Cookie 里,演示数组选项写法 setcookie('last_login', (string) time(), [ 'expires' => time() + 86400, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', ]); echo "登录成功: {$user}\n"; echo '会话 ID: ', session_id(), "\n";me.php:
<?php declare(strict_types=1); session_start(['use_strict_mode' => 1]); if (empty($_SESSION['uid'])) { http_response_code(401); echo "未登录\n"; exit; } echo '当前用户: ', $_SESSION['name'], "\n"; echo '上次登录时间戳: ', $_COOKIE['last_login'] ?? '(无)', "\n";logout.php:
<?php declare(strict_types=1); session_start(['use_strict_mode' => 1]); $_SESSION = []; if (ini_get('session.use_cookies')) { setcookie(session_name(), '', [ 'expires' => time() - 42000, 'path' => '/', ]); } session_destroy(); echo "已登出\n";启动并验证:
php -S 127.0.0.1:8000 # 另开一个终端 # 1. 登录,同时把 Set-Cookie 头打印出来,并把 Cookie 存到文件 curl -i -c jar.txt "127.0.0.1:8000/login.php?user=alice&pass=secret" # 2. 带着 Cookie 请求,应当看到"当前用户: alice" curl -b jar.txt "127.0.0.1:8000/me.php" # 3. 登出 curl -b jar.txt "127.0.0.1:8000/logout.php" # 4. 再次访问,应当返回 401 curl -i -b jar.txt "127.0.0.1:8000/me.php"第 1 步的响应头里能看到Set-Cookie: PHPSESSID=...; path=/; HttpOnly; SameSite=Lax,登出后的第 4 步会返回 401,说明整条链路都通。
五、多机部署时的会话存储
默认的会话处理器把数据存成文件,多台应用服务器之间无法共享——用户在第 1 台登录,被负载均衡打到第 2 台就掉线。解决办法是换集中式存储,在php.ini里配置:
; 使用 Redis 作为会话存储,多台机器共享同一份会话数据 session.save_handler = redis session.save_path = "tcp://127.0.0.1:6379?timeout=1" session.use_strict_mode = 1换成 Redis 之后,session_regenerate_id()、session_destroy()这些调用方式完全不变,只是数据落到了共享存储上。
常见坑点
- ❌
session_start()之前有输出(空行、空格、UTF-8 BOM)✅ 把它放在脚本第一行,且文件存为无 BOM 的 UTF-8
任何字符一旦被发送,响应头就锁定了,会话 Cookie 下发不出去,表现为"登录没反应",日志里是那句经典的 "headers already sent"。
- ❌ 登录成功后不调用
session_regenerate_id()✅ 登录成功立即调用session_regenerate_id(true)
不换 ID 的话,攻击者事先拿到的会话 ID 在用户登录后就变成了有效的登录态凭证。
- ❌ 把访问令牌、用户余额、权限位放进 Cookie ✅ 放服务端 Session 或服务端校验
Cookie 是客户端可随意编辑的,读取时必须当成不可信输入。
- ❌ 登出只写
$_SESSION = []✅ 还要session_destroy()并让会话 Cookie 立即过期
只清数组不销毁会话,服务端文件还在,会话 ID 仍然能被复用。
- ❌ 用
setcookie($name, '', ['expires' => 0])删除 Cookie ✅ 用time() - 3600这样的过去时间
过期为 0 的语义会因浏览器而异,用明确的过去时间才稳妥;同时要复用写入时的path与domain。
- ❌
SameSite设为None却没开Secure✅ 两者必须同时满足
现代浏览器会直接拒绝这种组合,表现为跨站请求始终收不到 Cookie,而单站点测试完全正常。
- ❌ 往
$_SESSION里塞PDO连接、闭包等不可序列化的东西 ✅ 只存标量或可序列化的简单对象
会话写入时序列化失败,读取时整个会话数据都拿不回来,报出的错误信息往往和"序列化"离得很远。
- ❌ 用
md5(uniqid())自己造会话 ID ✅ 交给session_create_id(null)或直接使用 PHP 生成的 ID
自造的 ID 可预测性不可控,而会话 ID 一旦被猜中,攻击者不需要密码就能进入账号。
总结
| 需求 | API | 关键参数 |
|---|---|---|
| 写 Cookie | setcookie() | 数组选项里的expires、httponly、samesite |
| 读 Cookie | $_COOKIE | 一律按不可信输入校验 |
| 删 Cookie | setcookie() | 过期时间设为过去,路径与域名保持一致 |
| 开启会话 | session_start() | use_strict_mode、cookie_httponly、cookie_samesite |
| 登录后换 ID | session_regenerate_id() | 传true同时删除旧会话文件 |
| 登出 | session_destroy() | 配合清空数组与删除 Cookie |
| 多机共享 | 会话存储处理器 | 指向 Redis 等集中式存储 |
Session 和 Cookie 都不是 PHP 8.0 的新功能,8.0 带来的变化主要在语法层面,以及自 7.3 起就可用的数组形式选项让SameSite、HttpOnly这些安全属性终于能在一个调用里写清楚。真正决定安全性的仍然是那几条老规矩:Cookie 里的内容永远不可信、登录成功必须更换会话 ID、登出要清数据与凭证、跨站场景下SameSite=None必须搭配Secure。