
先说一个我亲身处理过的事故一个跑了两年的服务数据库口令明文写在application-prod.yml里跟着代码一起进了 Git 仓库。业务侧一直没人管直到某次新同学排查历史问题时用了git log -p整份配置文件连带口令被翻了个底朝天。后来复盘时发现真正致命的不是那份配置而是这个口令在测试库、预发库、生产库三套环境里完全一样。这件事之后我把手上所有 Spring Boot 项目的配置文件敏感信息加密方案重新梳理了一遍也就有了这篇东西。这篇聊的是 Spring Boot 配置文件里敏感信息加密的三条主流路线Jasypt 加解密、自研 AES 配合 EnvironmentPostProcessor 提前解密、外部密钥托管Vault / KMS / 配置中心密文。三条路线的复杂度、运维成本、安全上限差别非常大选错了要么是加了个寂寞要么是把启动链路搞得脆弱不堪。适合手里有 Spring Boot 项目、正在纠结数据库密码和第三方 API Key 怎么放的开发同学也适合做安全评审时需要一个判断依据的运维同学。下面讲的内容我都会给到可跑的配置和代码也会讲清楚每一步为什么这么设计。1. 配置文件里的明文口令到底能被谁看见1.1 从一次代码审计扯出来的完整暴露链路很多人对配置文件明文的风险认知停留在仓库里能看到这一层实际上暴露面比这宽得多。我按实际排查过的顺序列一下代码仓库历史不只是当前分支git log -p、git blame、fork 出来的旧仓库、本地.git目录、CI 构建产物里的 jar 包BOOT-INF/classes/application.yml解压即得。改一次配置文件只能覆盖当前版本历史提交流水仍然完整保留。运行时环境ps -ef能看到--spring.datasource.passwordxxx这种启动参数同机器上的任何用户都能看/proc/pid/environ能看到环境变量容器编排平台的控制台上明文可见。Actuator 端点/actuator/env、/actuator/configprops、/actuator/beans这些默认或半默认开放的端点会把配置值直接吐出来。Spring Boot 3.3 之前/actuator/env对所有值一律脱敏成******但用户自定义的 key 不在内置脱敏名单里一样是明文。日志与异常栈spring.jpa.show-sqltrue配合org.hibernate.SQL的 DEBUG 日志某些驱动会把连接串打进日志连接失败时抛出的SQLException里经常带着完整 JDBC URLMyBatis 打印参数时把密码当普通参数输出。堆转储jmap -dump或jcmd GC.heap_dump出来的 hprof 文件里DataSource持有的口令是明文字符串用任意一个内存分析工具都能搜到。把这几条放在一起看结论就清楚了配置文件加密解决的核心是静态存储介质上的明文暴露也就是第一条和第二条的前半段。它解决不了堆转储也解决不了别人拿到机器权限之后的读取。所以选方案之前得先把威胁模型想明白。1.2 威胁模型决定了你该选哪种方案我一般把攻击者分成三档对应三种防护强度攻击者档位能力描述对应的防护要求A 档翻仓库的人只看得到代码和配置文件的静态内容拿不到运行环境配置文件密文化即可密钥不在文件里就够B 档有机器登录权限的人能看进程、环境变量、部分文件系统密钥必须来自文件之外、进程之外且能审计C 档有完整主机权限的人能 dump 内存、能读任意文件靠加密已经没意义需要动态凭据 短时效A 档用 Jasypt 就够。B 档必须让密钥的存放位置和密文的存放位置分离这也是自研 AES 方案存在的唯一理由。C 档只能上外部密钥托管让口令本身变成一个有效期几分钟的临时凭据而不是一个可以长期复用的静态字符串。提示很多团队的误区是拿 A 档的方案去防 C 档然后在意识层面觉得已经加密了就放松了其他管控。这比不加密更危险因为会产生虚假的安全感。2. 三种方案先摊开对比Jasypt、自研 AES、外部密钥托管2.1 Jasypt 的本质是一把共享密钥加一层属性包装Jasypt 的思路非常朴素用你给的一把主密钥master key对配置文件里的某个值做对称加密得到一串 Base64 密文再用ENC(...)包起来写回配置文件。应用启动时jasypt-spring-boot-starter会往 Environment 里塞一个后置处理器EncryptablePropertySource的包装读到ENC(开头的值就当场解密。它的优点和缺点都写在同一个地方实现简单代价是主密钥必须和密文同时存在于运行环境里。密钥通常通过启动参数、环境变量、JVM 系统属性或者一个独立的密钥文件传入只要攻击者能拿到运行环境的一部分组合起来就能还原明文。所以 Jasypt 很适合防 A 档攻击者但不适合自称能防 B 档。另一个容易忽略的点Jasypt 系列版本在 Spring Boot 3 上有明确的适配分界线jasypt-spring-boot-starter3.0.5 是能正常工作的版本再往前的 3.0.3 及以下在 Spring Boot 3 环境下会因为spring.factories加载机制变化导致自动配置不生效表现就是密文原样注入进去程序用着用着报认证失败。2.2 自研 AES EnvironmentPostProcessor 强在密钥源头自研方案的核心动作就一句话在 Spring 的 Environment 完成准备、但任何 Bean 还没创建之前把配置里带特殊前缀的值解出来替换掉。这个时机点对应的是EnvironmentPostProcessor接口的postProcessEnvironment方法。它相比 Jasypt 的价值不在于算法更强——两边用的都是对称加密——而在于控制权回到了自己手里密文格式可以自定义前缀后缀随便定不依赖第三方库的解析规则密钥来源可以做成任意组合环境变量 文件挂载 远程拉取甚至可以做成启动时从某个内网接口拉一次拉到之后缓存在内存里不落盘可以针对不同的配置项用不同的密钥比如数据库用一个第三方支付 API 用另一个实现密钥隔离能在解密失败时做定制化处理比如直接抛异常阻止启动防止用错误的配置启动生产环境。代价是这玩意儿得自己写、自己测写不好会在启动阶段引入很难查的问题。我在第 4 节会把完整实现和几个反直觉的坑都写出来。2.3 外部密钥托管解决的是人知道密钥这件事Vault、云厂商的 KMS、以及配置中心自带的密文能力解决的是一个更高层次的问题让应用在绝大多数情况下根本拿不到长期有效的口令。以 Vault 的数据库动态凭据为例应用启动时用身份凭证AppRole、Kubernetes ServiceAccount 等找 Vault 换一个数据库账号密码这个账号密码的有效期可能是 1 小时到期前由客户端自动续期或重新申请。这样一来配置文件里根本没有口令连密文都没有只有一个 Vault 地址和一个角色名即使某个开发同学把内存 dump 出来了拿到的凭据大概率已经过期权限回收和审计在 Vault 侧统一完成离职同学无法再复用旧口令。KMS 路线则是另一个方向把主密钥托管在 KMS 里应用启动时调用 KMS 的 Decrypt 接口把配置文件里的密文解开。相比本地存密钥KMS 的好处是密钥永不离开硬件边界且有完整的调用审计。代价是启动链路多了一次网络调用KMS 不可用时服务起不来。三种方案的横向对比我整理成一张表建议直接按这张表做初筛维度Jasypt自研 AES EnvironmentPostProcessor外部密钥托管Vault/KMS/配置中心引入成本加一个依赖 一行配置写 1 个类 1 个 SPI 注册文件需要部署/接入外部服务改启动链路密钥存放位置环境变量/启动参数/密钥文件可自由设计能做到不落盘密钥从不落地只有身份凭证能否防仓库泄露能能能能否防主机权限攻击有限部分取决于密钥来源设计能凭据短时效支持密钥轮换需要重新加密所有配置看实现原生支持启动期故障点少中解密逻辑自身可能出错多网络、鉴权、限流团队规模适配1-5 人小项目5-20 人、有多套密钥20 人以上或有明确合规要求3. Jasypt 接入的完整链路与三个真实坑3.1 依赖选择与 Spring Boot 3.x 的适配分界线依赖本身很简单但版本要卡准dependency groupIdcom.github.ulisesbocchio/groupId artifactIdjasypt-spring-boot-starter/artifactId version3.0.5/version /dependency如果你的项目是 Spring Boot 2.x3.0.5 同样兼容不需要降级。而如果你在 Spring Boot 3.x 上用了 3.0.3 及以下版本会看到一个非常迷惑的现象应用能启动但Value(${spring.datasource.password})注入进去的是ENC(xxx)这串原始文本然后在数据库连接时抛认证失败。原因在于 Spring Boot 3 之后spring.factories的加载机制被收窄旧的自动配置注册方式不再被识别EncryptablePropertySource压根没被装上。判断方式很直接启动日志里搜jasypt关键字正常情况下能看到一行关于EncryptablePropertySourceConverter的 debug 日志。看不到就是没生效。3.2 密文怎么生成CLI 参数一个都不能少生成密文最稳的方式是直接用 Jasypt 的 CLI注意算法参数必须和运行时的配置完全一致包括 IV 生成器java -cp jasypt-1.9.3.jar org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI \ inputMyDbPssw0rd \ passworda-very-long-and-random-master-key \ algorithmPBEWITHHMACSHA512ANDAES_256 \ ivGeneratorClassNameorg.jasypt.iv.RandomIvGenerator输出大概长这样----ENVIRONMENT----------------- Runtime: ... ----ARGUMENTS------------------- input: MyDbPssw0rd password: a-very-long-and-random-master-key algorithm: PBEWITHHMACSHA512ANDAES_256 ----OUTPUT---------------------- k9Xv2mQpL7dR4tNz1cYb8sHjF3wA6eUg...把OUTPUT那一段用ENC(...)包起来写回配置spring: datasource: url: jdbc:mysql://db-host:3306/app username: app_user password: ENC(k9Xv2mQpL7dR4tNz1cYb8sHjF3wA6eUg...) jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD} algorithm: PBEWITHHMACSHA512ANDAES_256 iv-generator-classname: org.jasypt.iv.RandomIvGenerator property: prefix: ENC( suffix: )这里有几个必须踩过一次才知道的细节ivGeneratorClassName不能省。Jasypt 1.9.3 默认用的是NoIvGenerator也就是固定 IV。而jasypt-spring-boot-starter3.0.5 内部默认用的是RandomIvGenerator。两边不一致的结果是加密时能出密文解密时报EncryptionOperationNotPossibleException。我第一次踩这个坑时查了两个小时最后发现是 CLI 和 starter 的默认值不同。同一个明文每次加密出来的密文都不一样因为 IV 随机这是正常的不用怀疑加密是否稳定。PBEWITHHMACSHA512ANDAES_256在部分 JDK 8 早期小版本上需要额外装 JCE Unlimited StrengthJDK 8u161 之后默认放开现代 JDK 11/17/21 都不需要处理。3.3 密钥往哪放这是整套方案的安全上限Jasypt 方案的安全性强弱完全取决于jasypt.encryptor.password这个值从哪来。按安全性从低到高排直接写在配置文件里——等于没加密唯一的作用是躲过关键词扫描稍微认真一点的攻击者都能找到。写在启动命令行里--jasypt.encryptor.passwordxxx——同主机的其他用户ps -ef即可看到容器环境下危险性更高。环境变量JASYPT_ENCRYPTOR_PASSWORD——比启动参数好但/proc/pid/environ仍然可读且容器编排平台控制台明文展示。独立的密钥文件文件权限600归属专用用户配置文件里只写路径——这是自建环境下比较靠谱的做法至少做到了密文和密钥不在同一份文件里。启动时从密钥管理服务拉取拉完只在内存里存不落盘——这已经接近外部托管方案的思路了。注意无论用哪种方式主密钥的长度都不能低于 32 个字符且必须是真随机。我见过有团队用company2024这种当主密钥PBEWITHHMACSHA512ANDAES_256 的密钥派生再强也扛不住这种熵值。我在实践里最常用的组合是第 4 种加第 5 种开发环境用密钥文件生产环境启动时通过一个 init 容器把密钥写到tmpfs内存文件系统挂载点上应用读文件、读完即缓存到内存容器销毁后文件自然消失。这样至少避免了密钥在磁盘上长期驻留。4. 自研 AES 解密EnvironmentPostProcessor 的正确打开方式4.1 为什么必须抢在 Bean 之前动手这是整个方案里最关键的设计点。很多同学的第一反应是写一个BeanPostProcessor或者ApplicationContextInitializer来做解密然后在某个Configuration里把解出来的值塞回 Environment。这个思路会踩一个非常隐蔽的坑自动配置的 Bean 在很早就已经绑定了属性。具体来说DataSourceAutoConfiguration通过ConfigurationProperties绑定spring.datasource.*这个绑定动作发生在 Bean 创建阶段你的后置处理器如果晚于这个点执行DataSource 拿到的还是密文。而EnvironmentPostProcessor是在Environment准备完成后、ApplicationContext刷新之前执行的这个时间窗口是唯一安全的位置。完整实现大概长这样public class SensitivePropertyDecryptingPostProcessor implements EnvironmentPostProcessor, Ordered { private static final String PREFIX enc:; Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { SecretKey key MasterKeyLoader.load(environment); MutablePropertySources sources environment.getPropertySources(); for (PropertySource? source : sources) { if (!(source instanceof EnumerablePropertySource)) { continue; } EnumerablePropertySource? enumerable (EnumerablePropertySource?) source; MapString, Object decrypted new HashMap(); for (String name : enumerable.getPropertyNames()) { Object raw enumerable.getProperty(name); if (raw instanceof String ((String) raw).startsWith(PREFIX)) { String plain AesGcmCipher.decrypt( ((String) raw).substring(PREFIX.length()), key); decrypted.put(name, plain); } } if (!decrypted.isEmpty()) { // 不替换原 source而是插一个更高优先级的 MapPropertySource sources.addFirst(new MapPropertySource( enumerable.getName() -decrypted, decrypted)); } } } Override public int getOrder() { // 必须晚于 ConfigDataEnvironmentPostProcessor // 否则自己写的 application.yml 还没被加载进来 return Ordered.LOWEST_PRECEDENCE; } }然后在src/main/resources/META-INF/spring/下建一个文件文件名是固定的org.springframework.boot.env.EnvironmentPostProcessor.imports内容就是全限定类名一行一个com.example.config.SensitivePropertyDecryptingPostProcessorSpring Boot 3.x 用这个.imports文件Spring Boot 2.4 到 2.7 也支持2.3 及以前只能写spring.factories。如果你的项目跨了这两个版本阶段两个都保留也不会冲突。还有两个必踩的细节这个类不能加Component。它的实例化时机早于容器扫描标注了也不会被当作组件加载而且往往会导致重复执行。getOrder()返回值很关键。返回HIGHEST_PRECEDENCE会让它比ConfigDataEnvironmentPostProcessor还早执行那时自己项目里的application.yml压根没被加载进来遍历 PropertySource 只能看到系统属性和环境变量结果就是什么都没解出来但程序正常启动——然后线上才报密码错误。我一般直接返回LOWEST_PRECEDENCE。4.2 算法选型AES-GCM 与 IV 的处理自研方案最容易写错的地方是加密模式。绝对不要用 ECB也尽量别用 CBC需要额外处理 padding oracle 问题。AES-GCM 是当前比较稳妥的选择它自带完整性校验密文被篡改会在解密时直接抛AEADBadTagException不会静默返回垃圾数据。public final class AesGcmCipher { private static final String TRANSFORMATION AES/GCM/NoPadding; private static final int IV_LENGTH 12; private static final int TAG_BITS 128; public static String encrypt(String plain, SecretKey key) throws GeneralSecurityException { byte[] iv new byte[IV_LENGTH]; new SecureRandom().nextBytes(iv); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); byte[] ct cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); byte[] combined new byte[iv.length ct.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(ct, 0, combined, iv.length, ct.length); return Base64.getEncoder().encodeToString(combined); } public static String decrypt(String base64, SecretKey key) throws GeneralSecurityException { byte[] combined Base64.getDecoder().decode(base64); byte[] iv Arrays.copyOfRange(combined, 0, IV_LENGTH); byte[] ct Arrays.copyOfRange(combined, IV_LENGTH, combined.length); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); return new String(cipher.doFinal(ct), StandardCharsets.UTF_8); } }关键点说明IV 必须是 12 字节。GCM 标准推荐 96 位 IVJava 的GCMParameterSpec传其他长度也能跑但性能会下降因为内部要做 GHASH 派生。IV 必须每次随机。同一把密钥下 IV 复用是 GCM 的致命错误会导致密钥流复用攻击者可以对两个密文做异或直接还原明文。把 IV 拼在密文前面存储是标准做法不用额外维护 IV 的存放位置。密钥长度用 256 位。KeyGenerator.getInstance(AES)配合init(256)即可JDK 8u161 之后不需要额外配置。GCMParameterSpec的 tag 长度用 128不要图省事用 96 位除非有明确的带宽约束。我在实现里还会给每一条密文加一个版本前缀比如v1:方便以后换算法时不至于全量重加密。密文格式变成enc:v1:Base64(...)解析时按冒号切分版本不匹配就走对应的解密分支。4.3 密钥来源的三种姿势与它们的差别密钥怎么加载决定了这个方案的上限我常用的三种方式实现优点缺点环境变量System.getenv(APP_MASTER_KEY)一行代码容器编排平台原生支持进程环境可读控制台可见密钥文件读指定路径的文本文件权限可精细控制利于审计文件可能被误提交需加.gitignore并做扫描内存挂载读 init 容器写入tmpfs的文件密钥不落磁盘容器销毁即失编排配置略复杂需要平台支持实际项目里我一般会做成按优先级依次尝试先看环境变量有没有没有就读挂载路径都没有就抛异常直接终止启动。这样做的好处是本地开发、CI、生产可以用同一套代码只是密钥的提供方式不同。public final class MasterKeyLoader { private static final String ENV_NAME APP_MASTER_KEY; private static final String DEFAULT_PATH /etc/secrets/master.key; public static SecretKey load(ConfigurableEnvironment env) { String raw env.getProperty(ENV_NAME); if (!StringUtils.hasText(raw)) { String path env.getProperty(app.crypto.key-file, DEFAULT_PATH); raw readFirstLine(Path.of(path)); } if (!StringUtils.hasText(raw)) { throw new IllegalStateException( master key not found, refuse to start); } return new SecretKeySpec( Base64.getDecoder().decode(raw.trim()), AES); } private static String readFirstLine(Path path) { try { return Files.readAllLines(path).stream().findFirst().orElse(null); } catch (IOException e) { return null; } } }这里有个设计上的取舍值得说清楚密钥找不到时一定要抛异常终止启动不能降级成用密文当明文继续跑。后者会导致两个后果——应用起来了但连不上数据库或者更糟连上了错误的库。抛异常虽然让启动失败但故障位置非常明确排障时间反而短。5. 外部密钥托管Vault、KMS 与配置中心密文各管什么5.1 动态凭据真正解决的是轮换问题前两种方案有一个共同的软肋轮换。数据库密码一旦要改意味着所有环境的密文都得重新生成、重新提交、重新发布。在一个有几十个服务的体系里这个动作的成本高到没人愿意做于是密码一用就是三年。Vault 的数据库引擎把这件事变成了自动的。它的大致工作方式是Vault 里配置一个能创建用户的数据库账号比如 MySQL 的CREATE USER权限应用每次申请凭据时Vault 就现场创建一个临时用户并设置 TTL比如 1 小时到期自动DROP USER。应用侧只需要引入spring-cloud-vault-config-databases之类的依赖配置好 Vault 地址和角色名spring.datasource.username和password会被自动注入。几个实际落地时要注意的点TTL 不能设太短。设成 5 分钟会导致 Vault 的负载很高而且一旦 Vault 短时不可用所有服务续期失败会在几分钟内集体挂掉。一般 1 小时是个比较平衡的值。要处理续期失败。spring-cloud-vault有内置的续期机制但仍然要监控续期失败的指标提前告警。数据库连接池的凭据刷新。HikariCP 不会自动感知凭据变化spring-cloud-vault是通过VaultDatabaseProperties配合DataSource的refresh能力实现的用的是 HikariCP 的setCredentials接口。如果你的项目用的是 Druid这套机制需要自己接。5.2 KMS 信封加密的启动期成本KMS 路线的形态是配置文件中存密文密文是用 KMS 里的主密钥加密的。应用启动时调用 KMS 的 Decrypt 接口拿到明文。相比本地存密钥它的核心优势是密钥不会离开 KMS 的硬件安全边界且每次调用都有日志。但这条路线有一个常被低估的成本启动时长和可用性耦合。原来启动只要读本地文件现在是同步 HTTP 调用正常情况下多几十毫秒但如果 KMS 服务有抖动或者限流启动时间会被拉长到秒级甚至更久。我在一个项目里遇到过 KMS 侧 QPS 限流扩容时 20 个 Pod 同时启动全部卡在解密调用上滚动发布超时失败。应对办法有两个一是减少调用次数把所有密文合并成一个 JSON 一次解密而不是逐个调用二是加本地缓存把解密结果缓存到磁盘的临时目录同时记录密文指纹指纹一致就直接用缓存但这又引入了密钥材料落盘的问题属于拿安全性换可用性需要评估。5.3 小团队到底该不该上我给判断标准一般是三条满足任意一条就值得上服务数量超过 10 个且共享同一批数据库/中间件凭据——这时候手工轮换的成本已经超过接入成本。有明确的合规要求比如需要提供凭据访问审计和凭据有效期这两个能力。有过一次凭据泄露事故团队对轮换的接受度已经建立起来。反过来如果只是三五个服务、业务没那么敏感、团队里没有人专门维护基础设施硬上 Vault 的结局往往是装完之后没人管Unseal 的密钥放在一个共享文档里比原来的方案还危险。这种情况老老实实用 Jasypt 加个独立的密钥文件把精力放到密码不要跨环境复用和离职同学回收权限这两件事上收益更大。配置中心自带的密文能力比如 Nacos 的加解密插件机制、Spring Cloud Config 的{cipher}前缀属于中间形态密文和配置放在一起密钥由服务端统一管理。它比 Jasypt 好一点的地方在于密钥不用下发到应用侧但比 Vault 弱的地方在于没有动态凭据。适合已经有了配置中心、不想再引入新组件的团队。6. 落地清单选型、验证与解密失败的排查顺序6.1 一张决策表按现状直接对号入座你的现状推荐方案关键动作单机部署3 个以内的服务无专职运维Jasypt 独立密钥文件用 3.0.5 版本CLI 参数与运行时对齐容器化部署5-20 个服务需要密钥隔离自研 AES EnvironmentPostProcessor密钥走挂载卷找不到直接终止启动已有 Vault 或云 KMS20 服务外部密钥托管减少调用次数加启动超时与告警已有配置中心不想引入新组件配置中心密文 服务端密钥管理确认密文能力是否已启用密钥不下发6.2 解密失败时按这个链路排查不管用哪种方案解密失败的排查顺序基本一致我按实际处理过的频率排确认解密逻辑有没有被加载。Jasypt 看启动日志有没有EncryptablePropertySource相关的行自研方案在postProcessEnvironment里打一行日志确认方法被调用了。方法没被调用后面所有排查都是白费。确认 PropertySource 遍历顺序。自研方案最容易出问题的就是这里——application-prod.yml是ConfigDataEnvironmentPostProcessor加载进来的如果你的getOrder()返回了一个更小的值遍历时看不到它。打印一下environment.getPropertySources()的名字列表一眼就能确认。确认加密参数一致。Jasypt 的algorithm、ivGeneratorClassName、password三项必须两端完全一致任何一项不同都会报EncryptionOperationNotPossibleException。自研方案则要确认密钥长度32 字节还是 16 字节、IV 长度12 字节、tag 长度128 位。确认密文传输没被破坏。ENC(...)或enc:前缀被 YAML 解析器吃掉、Base64 里的和/被 URL 编码、配置文件编码不是 UTF-8 导致特殊字符变形——这几类问题在跨平台复制粘贴时特别常见。确认密钥本身正确。把密钥的 SHA-256 摘要打出来在加密端和解密端各算一次对比比人眼看字符串靠谱得多。曾经有个案例是密钥末尾多了一个换行符Windows 编辑器保存排查了半天。6.3 那些文档里不会写的细节最后补几个我在实际项目里攒下来的经验都属于不看别人的坑基本想不到的类型Actuator 的/env端点会绕过你的解密逻辑吗不会它读取的是Environment里的值所以你解密后的明文会明文展示在那里。Spring Boot 3.3 引入了management.endpoint.env.show-values配置项默认值never把值全部隐藏在这之前建议直接关掉env、configprops、beans这几个端点的暴露或者用management.endpoints.web.exposure.include精确控制。日志脱敏要单独做。Spring Boot 2.6 提供了SanitizingFunction接口可以注册一个 Bean 来对日志中的特定 key 做脱敏。但它的作用范围是 Spring 的日志事件你自己用log.info打印的字符串不在覆盖范围里别指望它能兜底。本地开发环境的密钥管理别偷懒。我见过团队在生产上做得很规范但本地idea的 Run Configuration 里明文挂着生产数据库密码然后这台笔记本被拿去送修了。我的做法是本地用一套独立的、指向本地 Docker 数据库的凭据跟生产完全隔离。配置文件加密不能替代权限管控。加密只是把看得懂的明文变成看不懂的密文如果仓库本身是公开的、或者访问权限管控松密文同样会被下载。两件事要一起做。灰度阶段先用 warn 级日志验证。自研方案上线时可以先不抛异常而是把解密失败的 key 用 warn 级别记下来观察一个发布周期确认没有遗漏再改成抛异常。这样能把因为一个边缘配置没加密导致整个服务起不来这种事故挡在灰度阶段。我个人在这些方案里用得最多的还是自研 AES 那套原因不是它技术上多先进而是它把密钥从哪来这个问题的答案完整地暴露在我的代码里我能一眼看清每个环境的安全边界在哪。用现成的库或者托管服务当然省事但出问题时排查链路会变长。最后分享一个小技巧写解密逻辑时先把它做成一个独立的main方法能脱离 Spring 单独跑通加解密和密钥加载确认没问题再往EnvironmentPostProcessor里塞能省下大量在启动日志里翻来翻去的时间。