PHP 服务用默认密钥上了生产——JWT 密钥缺失应快速失败
某天在做一次内部安全排查,翻到一个 PHP 聊天/IM 服务的认证逻辑,发现这样一行代码:
$secret = config('app.secret_key') ?: 'chat_secret_key_2024';看起来有兜底,实则是个定时炸弹。这个服务已经在生产跑了几个月,.env 里的 SECRET_KEY 从来没有被正确配置——因为谁都没报错,谁都不知道。
现象
- 生产环境一切正常,JWT 签发和验证均成功,日志无任何警告。
config('app.secret_key')返回空字符串(.env文件漏配)或仍是模板里的chat_secret_key_2024。- 任何人只要克隆仓库,读到这一行,就能用相同的密钥本地签一个
{ role: "admin", user_id: 1 }的 token,在生产环境验证通过。 - Workerman/Swoole 等长驻进程正常启动,从不提示配置错误。
根因
HMAC 本身不拒绝空 key 或弱 key。hash_hmac('sha256', $payload, '') 会安静地返回一个确定性签名,这意味着「用空密钥签的 token」在同样用空密钥的服务上是完全合法的。
三种失效模式并存:
- 空值穿透:
.env漏配 →config()返回null/''→?:兜底到字面量 → 服务正常运行。 - 占位符未替换:从另一个 repo 复制了
.env.example,其中SECRET_KEY = your_random_secret_key_here原样保留进了生产。 - 分散的兜底值:不同控制器各自内联了不同的 fallback 字符串,密钥轮换只改了部分代码,另一些悄悄还在用旧值。
坑:config() ?: '默认值' 让安全错误变成静默成功
$secret = config('app.secret_key') ?: 'fallback_secret' 这种写法在业务代码里属于「防御性编程」,但在密钥配置上是反模式:它把「配置缺失」这个应当立即崩溃的错误,悄悄转成了「用已知字面量继续运行」的静默成功。攻击者读了代码就等于拿到了密钥。
正解
把密钥访问收拢到一个 get_jwt_secret() helper,在该函数里做快速失败校验:
// app/common.php — 密钥的唯一访问入口
function get_jwt_secret(): string
{
$key = config('app.secret_key');
if (empty($key) || $key === 'your_random_secret_key_here') {
throw new \RuntimeException(
'[SECURITY] JWT SECRET_KEY 未配置或仍为占位符。'
. '请在 .env 的 [APP] 段设置随机密钥,例如: SECRET_KEY = '
. bin2hex(random_bytes(32))
);
}
return $key;
}所有签发/验证入口统一调用它,不再各自内联密钥读取:
function generate_token(int $user_id, string $role = 'user'): string
{
$secret = get_jwt_secret(); // 配置错误则抛出,不发 token
// ... 构建 header.payload,用 $secret 签名
}
function verify_token(string $token): ?array
{
try {
$secret = get_jwt_secret();
} catch (\RuntimeException $e) {
return null; // fail-closed:密钥缺失时拒绝一切 token
}
// ... 用 hash_equals 比对 HMAC
}对 Workerman / Swoole / 队列 worker 这类长驻进程,在启动入口主动调用一次,让进程在配置错误时拒绝启动:
// server/websocket.php — worker 入口
require __DIR__ . '/../app/common.php';
get_jwt_secret(); // 启动时断言;.env 配错则进程立即崩溃,日志报错审计存量代码的快速 grep:
# 找还在直接读密钥的调用点
grep -rn "config('app.secret_key')\s*\?:\s*'" app/ \
| grep -v "get_jwt_secret"
# 找绕过 helper 直接签名的代码
grep -rn "hash_hmac\|jwt_encode\|JWT::encode" app/ \
| grep -v "get_jwt_secret"正解:启动即断言,密钥缺失进程不起
把所有密钥读取收归一个 helper,在 helper 内对空值/占位符抛异常,并在 worker 入口调用一次。配置错误立刻暴露在部署阶段,而不是等到生产被人伪造 token 后才发现。
一句话外卖
任何服务密钥(JWT secret、HMAC key、签名盐)都不该有运行时兜底默认值——配置缺失应该让进程在启动时崩溃,而不是让攻击者用你 README 里的示例密钥签出合法 token。