适用场景

代码仓库、CI 日志、工单截图或聊天记录中出现了生产 API 密钥。密钥仍在被多个服务使用,直接禁用可能造成业务中断,但继续保留又会扩大攻击窗口。

本文面向服务到服务调用的静态 API 密钥,给出一套可执行的处置流程:先确认影响范围并限制风险,再创建权限更小的新密钥,通过短暂的双密钥窗口迁移调用方,验证新密钥流量后撤销旧密钥,最后完成审计和防复发。数据库密码、云平台访问密钥也可参考这套生命周期,但具体吊销方法应以对应平台的官方文档为准。

现象描述

常见告警包括:

  • Secret Scanning 在提交历史中发现疑似令牌。
  • CI 输出了完整环境变量或请求头。
  • 应用日志记录了 Authorization、查询参数或完整异常请求。
  • 同一密钥突然从陌生 IP、地域或 User-Agent 发起调用。
  • 密钥的调用量、失败率或访问资源范围明显偏离基线。

发现密钥后,第一原则是把它视为已经泄漏。删除当前文件或重写 Git 历史不能让已经复制出去的密钥失效,真正结束风险必须依赖服务端撤销或轮换。

先判断:立即吊销还是短暂并行

处置顺序取决于是否存在正在滥用的证据:

  1. 已确认滥用或密钥权限极高:立即撤销旧密钥,即使会造成短时故障;随后恢复调用方。此时安全止损优先于无停机。
  2. 仅发现暴露、尚无滥用迹象:先创建新密钥并完成调用方迁移,再撤销旧密钥。并行窗口应以分钟或小时计算,不应无限延长。
  3. 平台不支持多个有效密钥:准备维护窗口或引入代理层完成切换,不要用“暂时不处理”代替方案。

OWASP 将创建、轮换、撤销和过期视为密钥生命周期的必要环节,并强调密钥应可快速撤销。GitHub 的 Secret Scanning 文档也建议在发现暴露凭据后立即轮换;是否短暂并行,只是降低迁移中断的工程手段,不改变旧密钥必须失效的结论。

可能的泄漏路径

1. 硬编码进入仓库

密钥可能存在于当前文件、历史提交、分支、Tag、Issue 或构建产物中。只搜索主分支最新版本会漏掉历史暴露。

2. 日志或可观测系统记录敏感头

反向代理、APM、异常追踪和调试中间件都可能采集完整请求头。日志保存周期通常长于应用容器生命周期,删除容器并不能删除日志副本。

3. 权限和使用范围过大

多个服务共享同一把管理员密钥时,既无法判断泄漏来源,也无法只撤销单个调用方。跨环境复用还会让测试环境泄漏直接影响生产。

4. 轮换缺少消费者清单

团队知道如何创建新密钥,却不知道哪些定时任务、脚本和第三方系统仍使用旧密钥,最终只能长期保留两把密钥。

处置流程

第一步:建立事件记录并保存必要证据

记录发现时间、密钥标识、所属系统、权限范围、暴露位置和负责人。证据中只保留密钥指纹,不复制完整密钥。可以用 HMAC 生成内部排查指纹,避免直接对低熵凭据做裸哈希:

"""生成仅用于事件关联的 API 密钥指纹。"""

import hashlib
import hmac


def build_key_fingerprint(api_key: str, audit_hmac_key: bytes) -> str:
    """返回不可用于认证的短指纹。"""
    if len(audit_hmac_key) < 32:
        raise ValueError("audit_hmac_key 至少需要 32 字节")
    if not api_key:
        raise ValueError("api_key 不能为空")

    digest = hmac.new(
        audit_hmac_key,
        api_key.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return digest[:16]

audit_hmac_key 必须来自密钥管理系统,与业务 API 密钥分开保存。日志只记录 key_id 或上述指纹,禁止记录原始值、Authorization 头和完整请求体。

第二步:盘点消费者和权限

建立最小迁移清单:

字段 示例 用途
consumer billing-worker 明确调用方所有者
environment production 防止跨环境复用
key_id key_20260915_a 审计与撤销定位
scopes invoice:read 校验最小权限
deployment billing-worker-v42 确认哪个版本完成切换
last_seen_at UTC 时间 判断旧密钥是否仍有流量

如果调用方无法通过 key_id 区分,应先升级认证协议。只有一个无标识的密钥字符串时,服务端很难安全审计和灰度轮换。

第三步:创建权限更小的新密钥

新密钥应满足以下约束:

  • 每个调用方、每个环境使用独立密钥。
  • 只授予当前接口需要的 scope,不复制旧密钥的全部权限。
  • 设置明确过期时间和负责人。
  • 仅在创建瞬间展示明文,服务端保存不可逆校验值。
  • 通过密钥管理系统或工作负载身份交付,不通过聊天和工单传递。

下面是一个 PostgreSQL 元数据表。示例只保存 secret_digest,不保存明文:

CREATE TABLE api_credentials (
    key_id text PRIMARY KEY,
    consumer text NOT NULL,
    environment text NOT NULL,
    secret_digest bytea NOT NULL,
    scopes text[] NOT NULL,
    status text NOT NULL CHECK (status IN ('active', 'grace', 'revoked')),
    expires_at timestamptz NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    revoked_at timestamptz,
    last_seen_at timestamptz
);

CREATE UNIQUE INDEX api_credentials_active_consumer_key
    ON api_credentials (consumer, environment, key_id)
    WHERE status IN ('active', 'grace');

grace 表示仅用于短暂迁移的旧密钥。生产实现还应记录创建人、撤销原因和审计事件,并限制只有认证服务能读取校验值。

第四步:让认证端短暂接受新旧两把密钥

请求应同时携带公开的 key_id 和私密的 secret,例如:

Authorization: ApiKey key_20260915_a.REDACTED_SECRET

服务端先按 key_id 查询候选记录,再使用恒定时间比较校验密钥,避免遍历全部凭据。以下示例演示核心边界:

"""校验带 key_id 的 API 密钥。"""

import hashlib
import hmac
from dataclasses import dataclass
from datetime import UTC, datetime


@dataclass(frozen=True, slots=True)
class Credential:
    """表示认证服务读取到的凭据元数据。"""

    key_id: str
    secret_digest: bytes
    status: str
    expires_at: datetime


def digest_secret(secret: str, server_hmac_key: bytes) -> bytes:
    """生成服务端保存的密钥校验值。"""
    return hmac.new(
        server_hmac_key,
        secret.encode("utf-8"),
        hashlib.sha256,
    ).digest()


def verify_api_key(
    presented_secret: str,
    credential: Credential,
    server_hmac_key: bytes,
    now: datetime | None = None,
) -> bool:
    """校验状态、过期时间和密钥内容。"""
    current_time = now or datetime.now(UTC)
    if credential.status not in {"active", "grace"}:
        return False
    if credential.expires_at <= current_time:
        return False

    presented_digest = digest_secret(presented_secret, server_hmac_key)
    return hmac.compare_digest(presented_digest, credential.secret_digest)

示例中的服务端 HMAC 密钥同样需要由密钥管理系统托管。认证失败日志应包含 key_id、调用方、来源网络、状态码和 trace_id,但不得包含 presented_secret

第五步:分批迁移调用方

建议按以下顺序切换:

  1. 将新密钥写入密钥管理系统的新版本,不覆盖旧版本。
  2. 先部署一个调用方实例或小比例任务。
  3. 确认新 key_id 的成功率、延迟和权限拒绝符合预期。
  4. 逐批更新剩余实例、定时任务和灾备环境。
  5. 查询旧 key_id 的最后使用时间,确认超过最长任务周期和连接缓存时间。

不要把新密钥放进镜像、Git 配置文件或普通环境清单。环境变量虽然比硬编码好,但仍可能出现在进程转储、诊断页面或错误日志中;具备条件时应使用短期动态凭据或工作负载身份。

第六步:撤销旧密钥并验证

迁移完成后把旧密钥标记为 revoked,不要只从调用方配置中删除。验证至少覆盖:

  • 使用新密钥调用允许的接口返回成功。
  • 新密钥访问未授权资源返回 403。
  • 旧密钥无论来自哪个实例都返回 401。
  • key_id 再次出现时触发安全告警。
  • 线上不存在仍引用旧密钥版本的实例、任务或灾备配置。

可用以下单元测试验证认证边界:

"""验证 API 密钥轮换期间的认证规则。"""

import unittest
from datetime import UTC, datetime, timedelta

from api_key_auth import Credential, digest_secret, verify_api_key


class ApiKeyAuthTest(unittest.TestCase):
    """覆盖有效、撤销、过期和错误密钥场景。"""

    def setUp(self) -> None:
        self.hmac_key = b"a" * 32
        self.now = datetime(2026, 9, 15, tzinfo=UTC)
        self.secret = "example-secret-for-test-only"

    def build_credential(self, status: str = "active") -> Credential:
        return Credential(
            key_id="key_test_a",
            secret_digest=digest_secret(self.secret, self.hmac_key),
            status=status,
            expires_at=self.now + timedelta(hours=1),
        )

    def test_active_key_is_accepted(self) -> None:
        self.assertTrue(
            verify_api_key(
                self.secret,
                self.build_credential(),
                self.hmac_key,
                self.now,
            )
        )

    def test_revoked_key_is_rejected(self) -> None:
        self.assertFalse(
            verify_api_key(
                self.secret,
                self.build_credential("revoked"),
                self.hmac_key,
                self.now,
            )
        )

    def test_wrong_key_is_rejected(self) -> None:
        self.assertFalse(
            verify_api_key(
                "wrong-secret",
                self.build_credential(),
                self.hmac_key,
                self.now,
            )
        )


if __name__ == "__main__":
    unittest.main()

运行:

python -m unittest -v test_api_key_auth.py

第七步:清理暴露位置

只有在旧密钥已经撤销后,才进入清理阶段:

  1. 从当前代码、CI 变量、Wiki、工单、制品和日志中删除明文。
  2. 评估是否需要重写 Git 历史;这会改变提交哈希并影响所有协作者,必须单独制定计划。
  3. 检查仓库 Fork、缓存、镜像层和下载制品等副本。
  4. 保留不含秘密的事件时间线、指纹和审计记录。

历史清理不是撤销的替代品。即使无法彻底删除所有副本,只要服务端已撤销旧密钥,副本就不能继续认证。

监控与告警

轮换过程至少观察以下指标:

api_auth_requests_total{key_id,status_code}
api_auth_last_seen_timestamp_seconds{key_id}
api_auth_revoked_key_attempts_total{key_id}
api_auth_permission_denied_total{consumer,scope}

key_id 可以记录,原始密钥不可以作为标签。标签中也不要放用户 ID、完整 URL 查询参数或其他高基数字段。旧密钥撤销后仍有请求,可能是遗漏的合法消费者,也可能是攻击者;两种情况都必须调查。

预防措施

  1. 开启仓库 Secret Scanning 和 Push Protection,在提交进入共享仓库前阻断已知格式的密钥。
  2. CI 日志默认脱敏敏感变量,禁止 set -x、打印完整环境或记录 Authorization 头。
  3. 每个调用方和环境独立发放凭据,落实最小权限、过期时间和所有者。
  4. 优先采用短期动态凭据、工作负载身份或可自动轮换的密钥管理系统。
  5. 每季度演练“创建新密钥、灰度迁移、撤销旧密钥、回滚”的完整流程,并记录耗时。
  6. 为每把静态密钥维护消费者清单和最后使用时间,发现长期未使用时主动撤销。
  7. 对撤销密钥的再次使用建立高优先级告警,但日志只保留 key_id 和必要上下文。

参考资料

总结

API 密钥泄漏处置的核心不是“把那一行代码删掉”,而是缩短凭据仍可被利用的时间。没有活动攻击迹象时,可以通过新旧密钥短暂并行实现无停机迁移;一旦确认滥用,则应立即撤销旧密钥,把安全止损放在可用性之前。

成熟的方案必须覆盖完整生命周期:独立身份、最小权限、安全交付、短期并行、流量验证、服务端撤销、审计告警和防泄漏控制。只有旧密钥在服务端明确失效,并且新密钥的权限和使用范围得到验证,轮换才算真正完成。