做后端开发这几年只要接口走的是 API 密钥认证密钥轮换就是一个绕不开的话题。Spring Boot 3.x 项目里密钥轮换的坑尤其隐蔽表面上看更新一下数据库里的 key客户端换一下配置就行但实际一操作不是线上大量 401就是新密钥迟迟不生效。我最早接手的一个“某跨平台系统”就是这样上线一年多都没人碰过密钥直到有次客户离职需要回收权限为了轮换一个密钥前后改了配置、重启了服务还是让部分调用方整整断了 40 分钟。这篇文章我想把 Spring Boot 3.x 下做 API 密钥认证时密钥轮换机制的设计思路、落地代码、常见事故和排障经验一起整理出来。重点不是教你写一个能“校验 key”的过滤器而是让你明白轮换机制的核心是把密钥当作“有版本、有状态、可以同时存在多个”的资源来管理。1. 为什么密钥轮换会成为线上事故1.1 一次典型的轮换事故复盘假设当前服务端只认识密钥v1-xxx客户端部署在几十台机器上配置里都写着这一个 key。某天你拿到新生成的v2-yyy把数据库里的 key 改成新值顺手把老 key 从配置里删除然后重启服务。这时候服务端已经完全不认识v1-xxx但客户端不是同一秒切换的有的机器已经拉到了新配置有的还保留着旧配置。结果服务端对所有旧请求统一返回 401监控面板上一片告警。从事故表象看是“切换时机没控制好”但根子在于设计上少了一个状态密钥不是只有“当前 key”这一个概念而是应该有“多个 key 可以同时有效”的阶段。服务端一旦只允许一个 key那么任何轮换动作都必然存在一瞬间的不可用窗口。只要客户端不是原子化同步切换这个窗口就会被放大成事故。1.2 API 密钥和 JWT 的本质差异很多人会用 JWT 的思路来理解 API key。JWT 依赖签名公钥只要公钥证书还在有效期内服务端就可以验证 token换公钥时旧 token 也还能继续验。API 密钥不一样尤其你用的是“一个字符串作为凭证”的方式服务端必须查到这个 key才能判断请求是否合法。如果服务端只有一个 key那旧 key 一旦被覆盖旧请求立刻失效。更麻烦的是 API key 的客户端往往不是单个应用可能是多个后端服务、多个脚本、多台机器。你无法保证所有客户端在同一时刻完成配置更新。所以轮换机制必须提前设计而不是等到要轮换的那天才临时改。另外还要区分两类 API 密钥随机字符串型 opaque token服务端只存哈希请求时把原始 key 带过来服务端重新哈希后比对。对称签名型 HMAC Key服务端需要拿到密钥原始值才能计算或校验请求签名。这两种类型在轮换时都会遇到“新旧共存”的问题区别只在于存储方式。随机字符串可以只存哈希HMAC Key 需要加密存储或交给 KMS 处理。1.3 轮换机制要解决的三类核心问题一个合格的密钥轮换机制至少要回答三个问题新密钥怎么安全发布不能靠人肉改配置、重启服务也不能把密钥明文扔在 Git 仓库里。过渡期怎么平滑共存新 key 生效后旧 key 还要能在一定时间内继续被接受给客户端留出切换窗口。旧密钥怎么安全回收不是“不改了就完事”而是要确认旧 key 已经没有调用流量再把它停用并清理。很多团队的密钥轮换方案只覆盖了第一点后两点基本靠运气。下面我从设计模型开始讲因为一旦模型对了代码怎么写都会顺。2. 核心设计把 API 密钥当成有版本、多状态的资源2.1 先落一张密钥表做密钥轮换我不建议再像传统项目那样把 key 写在application.yml的某个api-key.value字段里。这样一来换 key 要改配置、要重启而且完全没有版本概念。更好的做法是建一张密钥表让服务端从“一个 key”变成“一组 key”。我常用的表结构是这样的create table api_key ( id bigint generated always as identity primary key, key_id varchar(64) not null unique, key_name varchar(128) not null, secret_hash varchar(255) not null, status varchar(16) not null default ACTIVE, effective_at timestamptz not null, expire_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index idx_api_key_status on api_key(status);key_id是客户端请求时携带的公开标识它不是密钥本身泄露了也不至于让 attacker 直接拿走凭证。比如v-20240601,pay-2fa-key,client-a-key-v3都行。secret_hash是密钥哈希值如果项目里使用的是 HMAC Key需要存储可逆加密后的 secret字段可以换成secret_encrypted后面我会单独说。status是轮换机制的灵魂effective_at控制生效时间expire_at控制过期时间。有了这些字段服务端就能同时加载多个 ACTIVE 状态的 key并让它们在同一段时间内都通过校验。2.2 状态机让密钥先共存再回收我推荐至少保留这几个状态状态含义服务端行为使用场景STAGED预发布不接受该 key 调用只作为登记刚生成、还没正式启用ACTIVE有效接受该 key 调用客户端可以正常使用RETIRING待回收仍接受但记录告警/统计客户端已基本切换完观察剩余流量RETIRED已回收拒绝调用确认无流量后停用DISABLED紧急停用立即拒绝密钥泄露或某客户端被临时封禁轮换的常规流程是生成新 key 后先STAGED没有问题后改成ACTIVE此时旧 key 不会被删除继续保持ACTIVE于是新旧 key 同时有效。等客户端全部切到新 key把旧 key 改成RETIRING观察几天确认没有流量了再改成RETIRED。为什么不能直接从ACTIVE跳到RETIRED因为客户端做不到瞬时切换。哪怕你觉得自己已经通知到位了也一定会有漏掉的服务、定时任务、离线脚本。保留一个过渡窗口就是把这种不可能完全消除的“更新缝隙”兜住。2.3 密钥存哪里、怎么存密钥表建好之后还要想清楚列里放什么。如果你的 API key 是随机生成的高熵字符串建议只存密钥的 SHA-256 值。客户端每次请求都把 key 传过来服务端做一次哈希再比对。这样即使数据库泄露攻击者也拿不到原始 key。注意要使用MessageDigest.isEqual这类常量时间比较不要用普通的String.equals避免时间侧信道攻击。如果项目用的是 HMAC Key服务端需要用原始密钥去计算 HMAC 签名这时候就不能只存哈希了。可以选择加密存储比如用 AES-GCM 加密后放在数据库更稳妥的方案是把密钥托管到 KMS服务端运行时通过 KMS 的本地缓存或 API 解密后使用。Spring Boot 3.x 项目里可以直接集成对应的云厂商 SDK核心逻辑一致都是“数据库里只有密文只有受信任的应用进程才能拿到明文”。不管哪种方式都不建议把密钥明文写在 yml 里长期保存。yml 里可以放一个不敏感的前缀标识比如api-key.current-key-id: v-20240601真实密钥必须落在受控存储里。2.4 客户端怎么告诉服务端用哪个密钥服务端必须能区分当前请求用的是哪个 key不然每次都要把所有 ACTIVE key 遍历一遍做哈希比对数据量大了效率很差而且不好统计“旧 key 还有没有流量”。我建议客户端把 key ID 和 secret 一起放在 Authorization 头里Authorization: ApiKey v-20240601:xxxxxxxxxxxxxxxxxxxx服务端先按冒号拆出key_id再根据key_id去内存注册表里找到对应密钥并校验。有些人喜欢单独用X-Api-Key-Id: v-20240601和X-Api-Key: xxxx两个头这也行但多一个头就多一个容易被漏掉的配置项。我实际项目中更常用ApiKey keyId:secret的单头方案网关层解析也方便。这里的keyId是明文传输的不要把它当成敏感信息。真正的凭证是后面的 secret继续走 HTTPS 加密传输即可。3. Spring Boot 3.x 落地认证过滤器与动态密钥注册表3.1 拦截器还是过滤器Spring Boot 3.x 基于 Spring Framework 6底子是 Jakarta EE。做 API 密钥认证我首选OncePerRequestFilter因为认证属于横切逻辑应该在进入 Controller 之前完成而不是等到 Spring MVC 的拦截器阶段。如果项目里没有引入 Spring Security可以注册一个ComponentOrder的过滤器。如果引入了 Spring Security更推荐用SecurityFilterChain把过滤器挂到链路里http.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class);这样所有匹配的请求都会先经过 API key 校验再进入后续业务逻辑。也别忘了给/health、/public/**这类路径做白名单避免健康检查也被密钥卡住。3.2 动态密钥注册表代码我习惯把“加载密钥”和“认证请求”拆成两个类一个是注册表负责保存当前有效的 key 集合一个是过滤器负责解析请求并校验。注册表的核心是不能用静态字段写死而是要在内存里维护一份会定时刷新的不可变快照。Component public class ApiKeyRegistry { private final Logger log LoggerFactory.getLogger(ApiKeyRegistry.class); private final JdbcTemplate jdbcTemplate; private volatile MapString, ApiKeyCredential activeKeys Map.of(); public ApiKeyRegistry(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } PostConstruct public void init() { reload(); } Scheduled(fixedDelayString ${api-key.refresh-ms:30000}) public void reload() { ListApiKeyCredential loaded jdbcTemplate.query( select key_id, secret_hash, key_name from api_key where status ACTIVE and effective_at now() and (expire_at is null or expire_at now()) , (rs, rowNum) - new ApiKeyCredential( rs.getString(key_id), rs.getString(secret_hash), rs.getString(key_name) )); activeKeys loaded.stream() .collect(Collectors.toUnmodifiableMap( ApiKeyCredential::keyId, Function.identity())); log.info(api key registry reloaded, active count{}, activeKeys.size()); } public OptionalApiKeyCredential findActive(String keyId) { return Optional.ofNullable(activeKeys.get(keyId)); } }volatile在这里是必须的因为它保证了多线程下对activeKeys引用本身的可见性。每次reload()都是先查询数据库再整体替换整个 Map请求线程要么看到旧快照要么看到新快照不会出现遍历过程中被修改的问题。密钥表更新后最迟api-key.refresh-ms时间就能生效。如果你嫌弃 30 秒太慢可以改成 10 秒但要注意数据库压力。对于大多数内部系统30 秒完全够用。3.3 认证过滤器完整实现过滤器负责四件事解析请求头、查 key、校验密钥、放行或返回 401。Component Order(Ordered.HIGHEST_PRECEDENCE 100) public class ApiKeyAuthFilter extends OncePerRequestFilter { private final ApiKeyRegistry keyRegistry; public ApiKeyAuthFilter(ApiKeyRegistry keyRegistry) { this.keyRegistry keyRegistry; } Override protected boolean shouldNotFilter(HttpServletRequest request) { String path request.getRequestURI(); return path.startsWith(/actuator/health) || path.startsWith(/public/); } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String header request.getHeader(Authorization); if (header null || !header.startsWith(ApiKey )) { writeUnauthorized(response, missing api key); return; } String credential header.substring(7).trim(); int sep credential.indexOf(:); if (sep 0) { writeUnauthorized(response, invalid api key format); return; } String keyId credential.substring(0, sep).trim(); String secret credential.substring(sep 1); ApiKeyCredential key keyRegistry.findActive(keyId).orElse(null); if (key null || !checkSecret(secret, key.secretHash())) { writeUnauthorized(response, invalid api key); return; } request.setAttribute(api-key-id, keyId); chain.doFilter(request, response); } private boolean checkSecret(String inputSecret, String storedHash) { String inputHash sha256Hex(inputSecret); return MessageDigest.isEqual( inputHash.getBytes(StandardCharsets.UTF_8), storedHash.getBytes(StandardCharsets.UTF_8)); } private String sha256Hex(String value) { try { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(value.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hash); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\ message \}); } }注意sha256Hex里的HexFormat是 Java 17 自带的工具类Spring Boot 3.x 恰好要求 Java 17 起步所以直接用没问题。如果你的团队还用 Java 11手写一个 hex 转换就行不影响整体设计。3.4 缓存与刷新策略注册表用内存缓存虽然会有最多 30 秒的延迟但好处是认证路径上没有数据库查询性能稳定。大多数 API 的 QPS 都远高于密钥表变更频率这是非常划算的取舍。有一个细节我要特别提醒不要在过滤器里每次请求都查一次数据库。有人觉得这样“最实时”但一旦 QPS 上来数据库连接池会被认证查询打满业务查询也跟着受连累。密钥轮换本身是低频操作完全没必要为它牺牲主链路性能。如果你的配置中心支持动态刷新比如用 Nacos、Apollo也可以把密钥表放在配置中心然后通过监听器刷新本地 Map。我不反对这种方式但要注意一个问题配置中心里的密钥往往是对所有人可见的明文而 API 密钥属于高敏凭据我更倾向数据库/KMS 而不是 Git 化的配置中心。3.5 引入 Spring Security 时的注意点如果你的 Spring Boot 3.x 项目已经有spring-boot-starter-security那么过滤器就不能只写一个Component就完事。Spring Security 自己的过滤器链会先执行如果链路里没有放行你的 API key 过滤器请求可能根本走不到 Controller。正确的做法是定义SecurityFilterChainBean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth - auth .requestMatchers(/health, /public/**).permitAll() .anyRequest().authenticated()) .addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }加了addFilterBefore后所有需要认证的请求都会先经过 API key 过滤器。如果你在过滤器里校验通过后还想让 Spring Security 认识当前用户可以往 SecurityContext 里放一个Authentication对象。这样 Controller 里就能用AuthenticationPrincipal拿到 keyId 之类的信息。4. 一次完整的密钥轮换操作流程4.1 生成新的 API 密钥生成密钥一定要用安全的随机源不要拿 UUID 或者时间戳拼接。UUID 虽然随机性还行但它本身是 128 位而且部分位段规律明显不适合直接当密钥用。我常用 Java 生成 256 位随机密钥byte[] keyBytes new byte[32]; SecureRandom secureRandom new SecureRandom(); secureRandom.nextBytes(keyBytes); String rawSecret Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);或者运维侧直接openssl rand -base64 32生成后的原始 key 只展示一次负责生成的人记录下来后立刻写入密钥存储系统。数据库里只保存sha256(rawSecret)原始 key 不要写进日志也不要长期放在聊天工具里。4.2 发布新密钥发布时直接插入一行新记录不动旧记录insert into api_key(key_id, key_name, secret_hash, status, effective_at) values (v-20240601, pay service, sha256值, ACTIVE, now());新 key 是ACTIVE服务端在下一次注册表刷新后就会接受它。此时旧 keyv-20230601仍然是ACTIVE所以老客户端不会感知到任何变化。这个阶段可以先用新 key 手动调几个接口做验证确认key_id解析、哈希比对都没问题再进入客户端切换。4.3 通知客户端切换先不要急着把旧 key 停掉。你需要给所有调用方留一个切换窗口。窗口长短取决于你们的发布节奏。我的经验是内部服务至少留 24 小时外部客户至少留 3 到 7 天。不要拍脑袋定一个“明天必须切完”的 deadline因为外部系统可能要走审批、发布窗口、跨团队协调。轮换窗口宁可长一点也不要在第 23 小时突然把旧 key 禁了。切换期间要盯两个指标新 key 的调用量是否在上涨。旧 key 的调用量是否在下降。如果你的接口网关或日志里记录了key_id这一步很好完成。看到旧 key 的流量归零或接近归零再进入下一步。4.4 回收旧密钥先把旧 key 置为RETIRINGupdate api_key set status RETIRING, updated_at now() where key_id v-20230601;RETIRING状态下服务端仍然接受该 key这样可以兜住最后的漏网之鱼同时你可以靠监控确认还有没有请求在用它。如果观察几天发现没有异常流量再置为RETIREDupdate api_key set status RETIRED, expire_at now(), updated_at now() where key_id v-20230601;之后的数据清理可以按公司规范来比如保留 90 天后删除方便溯源。要注意如果中间发现RETIRING后还有大量老流量说明客户端根本没切完那就把状态改回ACTIVE延后回收不要抱有侥幸心理。4.5 紧急轮换密钥泄露、核心开发离职、第三方安全事故这类场景下没有过渡期可讲必须立刻停用旧 keyupdate api_key set status DISABLED, updated_at now() where key_id v-20230601;同时快速生成新 key 并通知客户端。紧急轮换的关键是“先止血再恢复”不要因为担心影响面而拖时间。你可以在架构设计时预留这个开关但希望永远不会用到它。5. 常见问题与排障实录5.1 旧密钥“提前失效”的真相有次我接到反馈说旧 key 明明还在 ACTIVE 状态但客户端一直 401。查到最后发现注册表刷新 SQL 里用了now()但这里的 now 是数据库时间。运维为了对齐某些操作把数据库服务器的系统时间往前调了几个小时导致effective_at now()不成立。密钥的生效时间判断最好只依赖数据库本身的时间不要依赖应用服务器各自的时间。应用实例多了之后任何一台的时钟偏移都会造成“部分节点有效、部分节点无效”的诡异现象。另一个常见原因是代码里自己加了额外判断比如if (keyId.equals(registry.getDefaultKeyId())) { ... }这等于把“多 key 共存”模型又拉回了“单 key 覆盖”模式。只要有一个地方强制认为“只有 default key 才有效”旧 key 就会在你的轮换过程中提前失效。排查时优先搜一下代码里有没有类似getCurrentApiKey、getDefaultKeyId的硬编码逻辑。5.2 新密钥入库后仍然 401新 key 入库后服务端可能最多需要api-key.refresh-ms才会刷新内存注册表。如果你把刷新间隔配成了 5 分钟那线上就会存在 5 分钟的“新 key 不可用”窗口。我在实际项目里遇到更隐蔽的情况服务注册了多个实例刷新时间各自错开。A 实例已经 reload 出新 keyB 实例还停留在旧快照。如果客户端刚好打到 B 实例就会 401。解决思路有两种把轮换和发布分开新 key 提前STAGED先扩一批实例验证再全量ACTIVE。接受一个刷新周期内的“不一致”让客户端在调用失败时增加一次短时间重试重试期间服务端大概率已经完成刷新。很多团队把这个问题误判为“缓存 Bug”其实这是分布式环境下的正常延迟。只要新 key 发布和旧 key 回收之间留了足够的重叠期这几十秒的差异不会造成故障。5.3 客户端明明切到新 key为什么还是 401最常见的原因是客户端配置里把keyId和 secret 的格式拼错了。比如我们约定ApiKey v-20240601:xxxxx客户端写成了ApiKey xxxxx:v-20240601或者把 secret 尾部多复制了一个空格。还有一次排查了很久发现是客户端和服务端的 Base64 解码策略不一致。我们生成时用 URL-safe Base64 且不带 padding客户端却用标准 Base64 解码结果尾部字符不一致。建议在密钥接入文档里明确写出编码规则并在生成密钥时直接输出一个可以直接复制粘贴的完整字符串。5.4 密钥明文出现在日志里这个问题在轮换期间尤其容易发生。因为新 key 刚发布大家都在联调可能会在 Controller 里把整个 Authorization 头打出来或者全局异常处理器把请求对象序列化后记录。密钥一旦进入日志系统后续很难彻底清除。我的处理方式是两条网关层直接脱敏后再转发业务服务永远拿不到完整 Authorization 头。统一日志过滤器里对Authorization、X-Api-Key等头做正则替换把这些字段的值统一打成***。String safeHeader header.replaceAll((?i)ApiKey\\s\\S, ApiKey ***);格式化日志、异常堆栈、请求响应记录三个地方都要做缺一个都等于白做。5.5 频繁更新数据库导致抖动有些团队为了“实时生效”把注册表刷新时间改成 1 秒并且每次请求都检查数据库。密钥表本身不大但认证链路是所有请求都走的一旦 QPS 上来无谓的轮询查询会放大数据库压力。实际上轮换不是高频操作30 秒刷新已经足够了。如果你硬要求秒级生效那就引入配置中心的监听机制或者消息通知让密钥变更主动推送给各个实例而不是靠不断轮询数据库。6. 把密钥轮换做成平台能力6.1 给密钥加上业务维度前面说的密钥表是最小可用方案。等团队大了、接入方多了你还会需要额外的字段来支撑隔离和审计。我建议加这几个字段scope密钥授权范围比如只允许访问订单接口不允许访问退款接口。allowed_ips允许来源 IP可以在过滤器里做一层轻量限制。client_name归属方名称方便定位是哪个部门、哪个客户在调用。created_by谁申请的密钥出了问题知道找谁。有了这些信息轮换时就能按范围精准操作。某个客户泄露了密钥直接把这个客户对应的 keyDISABLED不影响其他客户而不是全局换一套密钥。6.2 可观测性轮换能不能做稳很大程度上取决于你有没有“看到”旧 key 流量的能力。记录key_id到访问日志里是成本最低的做法但要注意别把 secret 一起带进日志。再加两个监控指标当前 ACTIVE key 的数量正常情况下应该大于等于 2如果变成 1 说明轮换没有及时发起。旧 key 最近 1 小时调用量持续为 0 才能放心把状态改成RETIRED。我见过不少团队密钥表里就一条记录也没有调用方统计。轮换时只能靠“感觉已经没人用了”这种操作方式迟早出问题。6.3 和配置中心、KMS 结合的思路如果公司已经有 KMS可以把密钥的原始值托管到 KMS数据库里只存密文。应用启动时拉取密文解密后加载到内存轮换时更新 KMS 里的版本即可。这种方案比“数据库里存明文”安全很多也不需要每个客户端都知道新密钥的存在因为服务端会自动读取最新版本。没有 KMS 的中小团队用我的方案也完全够用密钥表加哈希存储加 ACTIVE 多版本加定时刷新。先把流程跑顺再考虑上更重的治理平台。6.4 我的一点实际体会如果非要给一句话建议我会说不要一开始就追求“自动轮换”“零感知切换”这些听起来很酷的机制先把密钥表、状态机、过渡期这三件事做好。我见过太多团队连旧密钥还有没有流量都摸不清就开始写自动化轮换任务结果自动生成的新 key 没人切换到客户端反而造成大面积 401。轮换窗口宁可长一点也不要为了追求整洁而提前把旧 key 干掉。密钥轮换不是“越快越好”而是“越稳越好”。把这个原则刻在脑子里Spring Boot 3.x 的 API 密钥认证就没那么可怕了。