
写在最前面系统属性这层东西平时谁都不会特意去看它可一旦里面塞了加密值问题就会变得很别扭——配置文件里写明文的那套经验全失效了。前段时间我接手一个项目运维在启动脚本里用-D传了一串加密后的数据库口令结果 Spring 容器起来之后拿到的还是密文连接池直接报认证失败。后来折腾了大半天才把整条链路捋顺索性把过程整理一下从属性源的优先级、解密层的插入位置到几个几乎必踩的坑一次说清楚。这篇内容适合已经写过 Spring Boot、但没系统研究过属性加载链路的朋友也适合正在给老项目补安全加固的同学照着做。1. 系统属性在 Spring Boot 属性链路里的真实位置1.1 一次 setProperty 之后值究竟被谁读走了System.setProperty(key, value)写进去的东西本质上是 JVM 全局的一张Properties表底层是Hashtable进程内所有线程共享生命周期跟 JVM 一致。Spring Boot 在启动早期会把这张表包装成一个PropertySource名字固定叫systemProperties塞进MutablePropertySources里。你后面写的Value(${xxx})、ConfigurationProperties、environment.getProperty(xxx)最终都会顺着这条链一路找下来。关键在于包装这两个字。Spring 拿到的不是你原始那张Properties而是一个只读视图getProperty每次调用都会实时去问 JVM。也就是说你在容器启动之后调System.setPropertyenvironment.getProperty是能立刻读到新值的。但已经完成绑定的 Bean、已经解析完的占位符不会自动刷新这一点后面会专门讲。理解这一层很重要。很多人以为系统属性就是环境变量的小名其实两者在 Spring Boot 里是两个独立的属性源名字分别是systemProperties和systemEnvironment优先级也不一样系统属性排在环境变量前面。这个顺序直接决定了你的加密值会不会被别的配置盖掉。1.2 属性源优先级决定了你的加密值会被谁覆盖Spring Boot 的属性源是有明确顺序的靠前的优先。下面这张表是我实际排查时整理的只列了日常最常撞上的几层优先级属性源典型来源1命令行参数--app.secret.xxx...2SPRING_APPLICATION_JSON环境变量里的 JSON3Servlet 相关参数少见4JNDI少见5systemProperties-Dkeyvalue、System.setProperty6systemEnvironment操作系统环境变量7random.*随机值8外部配置文件jar 同级的application.yml9jar 内配置文件classpath:/application.yml10PropertySource代码里声明的11默认值SpringApplication.setDefaultProperties从这个顺序能看出来两件事。第一系统属性天然压过application.yml你在配置文件里写同名 key 是盖不住它的这是好事方便运维用启动参数覆盖。第二命令行参数又压过系统属性所以如果有人用--app.secret.xxx明文启动你的加密逻辑就白做了——这一点在做兜底校验时值得加一条判断。还有个容易忽略的细节Profile 相关的配置文件application-prod.yml排在同名非 Profile 文件前面但它们整体都排在系统属性之后。所以只要 key 对得上系统属性永远赢。这也解释了为什么有些团队喜欢把所有敏感配置都通过-D注入而不是写进配置文件。1.3 三个必须提前想清楚的时间点做加解密之前有三个时间点必须先确认否则代码写得再漂亮也不生效。设置时间点System.setProperty是在SpringApplication.run()之前、之中还是之后调的之前最稳Spring 能完整读到之中取决于具体阶段之后只能保证environment.getProperty读到已经绑定的 Bean 拿不到。解密层插入时间点解密逻辑必须插在所有读取动作之前。Spring Boot 提供了EnvironmentPostProcessor、ApplicationContextInitializer、BeanFactoryPostProcessor三个常见钩子它们的执行阶段完全不同选错了就会出现配置文件里的能解密、系统属性里的不能这种诡异现象。占位符解析时间点Value的占位符由PropertySourcesPlaceholderConfigurer在 Bean 实例化阶段解析晚于环境准备。所以只要在环境准备阶段把属性源包一层Value就能拿到解密值。我见过最典型的翻车方式是在某个PostConstruct方法里调System.setProperty塞密钥然后指望Value能读到。结果 Bean 初始化顺序稍微一变读到的就是 null。这类问题排查起来极其费劲因为不报错只是值不对。2. 方案选型三种落地路径的边界与取舍2.1 路径一在设置源头就地解密最直接的做法是在main方法里、SpringApplication.run之前把系统属性里的加密值解密后重新setProperty回去。代码大概长这样public static void main(String[] args) { decryptSystemProperties(); SpringApplication.run(DemoApplication.class, args); } private static void decryptSystemProperties() { Properties props System.getProperties(); for (String name : props.stringPropertyNames()) { String raw props.getProperty(name); if (raw ! null raw.startsWith(enc:v1:)) { System.setProperty(name, AesGcmCipher.decrypt(raw)); } } }这段代码的优势是简单、无框架依赖、执行时机绝对早。缺点是它会把解密后的明文回写到 JVM 系统属性里任何能读System.getProperties()的代码包括某些诊断工具都能看到明文。另外它只处理系统属性配置文件里的加密值管不着如果你的项目两种来源都有就得维护两套逻辑。这条路径适合什么场景我总结下来是项目规模小、敏感配置全部走启动参数、团队不想引入额外依赖。反过来说如果配置文件里也有加密值或者需要按环境切换密钥版本就别选它了后期会很难受。2.2 路径二用 Jasypt 兜住所有属性源Jasypt 的 Spring Boot Starter 是这类需求里出场率最高的方案。它的思路是提供一个EncryptablePropertySourceConverter在容器 refresh 之前把所有PropertySource逐个包一层读取时遇到ENC(...)包裹的值就解密。配置成本很低加个依赖、配个密钥就行。它对系统属性同样有效因为systemProperties也在被包装的名单里。我实测过-Dapp.secret.db-passwordENC(xxx)这种写法Spring 读出来是解密后的值。具体流程是EnableEncryptablePropertiesBeanFactoryPostProcessor在BeanFactory后置处理阶段触发属性源转换此时Environment已经准备好了所以Value和ConfigurationProperties都能拿到明文。但 Jasypt 有几个需要提前知道的边界。它的密钥本身要通过jasypt.encryptor.password配置这个值如果也从系统属性来就得保证包装之前已经就位它的默认算法是PBEWITHHMACSHA512ANDAES_256需要 BouncyCastle 支持JDK 版本低的项目要额外引依赖它对值的前缀匹配是ENC(开头、)结尾如果你的密文里恰好包含这个模式会被二次解密产生莫名其妙的错误。还有一个隐蔽的问题Jasypt 的包装发生在BeanFactory后置处理阶段比EnvironmentPostProcessor晚。如果某个EnvironmentPostProcessor在更早的阶段就要读加密属性比如决定加载哪个配置文件那时候还是密文。这种情况虽然少见但一旦碰上就很难定位。2.3 路径三自己包装 PropertySource第三种是自己写一个EnvironmentPostProcessor在环境准备阶段遍历所有属性源把需要处理的那几个替换成解密包装器。这种做法可控性最高能精确控制包装哪些源、能自定义密文前缀、能支持密钥版本、能在解密失败时给出明确日志。代价是要自己写代码大概一百多行包括一个加解密工具类、一个PropertySource包装器、一个EnvironmentPostProcessor实现和注册文件。听着多其实拆开看每一块都不复杂后面第三章会给出完整实现。这条路径适合对安全有明确要求、或者需要和现有密钥管理流程对接的项目。比如你的密钥来自某个配置中心需要在启动时拉取那用自定义处理器就比 Jasypt 灵活得多。2.4 三条路径的横向对比维度源头就地解密Jasypt Starter自定义 PropertySource实现成本极低低中覆盖配置文件否是是覆盖系统属性是是是密钥版本支持需自己实现弱强明文残留会回写系统属性不回写不回写执行时机最早较晚可调排查难度低中中选型上我的建议是新项目、配置来源统一走启动参数的选路径一够用且省事已经在用 Spring Boot 且只想快速补上的选 Jasypt对密钥轮换、审计日志有要求的老老实实写自定义处理器。三条路径也可以混用比如配置文件走 Jasypt、系统属性走源头解密但混用会让排查变复杂不建议。3. 手写一个能识别系统属性的解密层3.1 先写一个靠谱的 AES-GCM 工具类算法选择上我不推荐再用PBEWithMD5AndDES这个在 Jasypt 老版本里是默认值强度已经不够看了。直接上AES/GCM/NoPadding带认证标签能同时防篡改。public final class AesGcmCipher { private static final String TRANSFORMATION AES/GCM/NoPadding; private static final int IV_LENGTH 12; private static final int TAG_LENGTH_BIT 128; private static final String PREFIX enc:v1:; private static final SecureRandom RANDOM new SecureRandom(); private AesGcmCipher() { } public static String encrypt(String plain, SecretKey key) throws Exception { byte[] iv new byte[IV_LENGTH]; RANDOM.nextBytes(iv); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BIT, iv)); byte[] cipherText cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); ByteBuffer buffer ByteBuffer.allocate(iv.length cipherText.length); buffer.put(iv).put(cipherText); return PREFIX Base64.getUrlEncoder().withoutPadding() .encodeToString(buffer.array()); } public static String decrypt(String encoded, SecretKey key) throws Exception { if (encoded null || !encoded.startsWith(PREFIX)) { return encoded; } byte[] all Base64.getUrlDecoder() .decode(encoded.substring(PREFIX.length())); ByteBuffer buffer ByteBuffer.wrap(all); byte[] iv new byte[IV_LENGTH]; buffer.get(iv); byte[] cipherText new byte[buffer.remaining()]; buffer.get(cipherText); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BIT, iv)); return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); } }几个点值得说明。Base64.getUrlEncoder()用的是 URL 安全字符集把和/换成了-和_这样密文丢进启动脚本、环境变量、URL 查询串都不会被转义。我就吃过这个亏标准 Base64 里的在某个 CI 平台的变量传递环节被转成了空格解密直接报填充异常查了两小时。前缀enc:v1:有两个作用一是避免误伤普通值二是为密钥轮换预留版本号。以后换密钥就上enc:v2:解密时按版本选对应密钥老数据不用一次性全刷。IV 长度选 12 字节是 GCM 的推荐值性能最好。认证标签 128 位是标准配置别为了省几个字节改成 96 位收益微乎其微。3.2 包装属性源时的关键选择Enumerable 不能丢写包装器的时候有个坑必须提前避开如果你把一个EnumerablePropertySource包装成普通的PropertySourcegetPropertyNames()就返回不了了。后果是ConfigurationProperties的宽松绑定失效、Environment.getProperty的下标查找失效、actuator 的/env端点看不到这些属性。所以包装类要按源的类型分两种实现或者直接写一个继承EnumerablePropertySource的版本public class DecryptingPropertySource extends EnumerablePropertySourcePropertySource? { private final SecretResolver resolver; private final PredicateString keyFilter; public DecryptingPropertySource(String name, PropertySource? source, SecretResolver resolver, PredicateString keyFilter) { super(name, source); this.resolver resolver; this.keyFilter keyFilter; } Override public Object getProperty(String name) { Object value source.getProperty(name); if (value instanceof String str keyFilter.test(name)) { return resolver.resolve(str); } return value; } Override public String[] getPropertyNames() { if (source instanceof EnumerablePropertySource? enumerable) { return enumerable.getPropertyNames(); } return new String[0]; } Override public boolean containsProperty(String name) { return source.containsProperty(name); } }SecretResolver负责真正的解密内部加一层ConcurrentHashMap缓存同一个密文只解一次。启动阶段属性可能被读几百次缓存能省下不少开销。public class SecretResolver { private final MapString, SecretKey keys; private final MapString, String cache new ConcurrentHashMap(); public SecretResolver(MapString, SecretKey keys) { this.keys keys; } public String resolve(String raw) { if (raw null || !raw.startsWith(enc:)) { return raw; } return cache.computeIfAbsent(raw, this::doResolve); } private String doResolve(String raw) { String version raw.substring(4, raw.indexOf(:, 4)); SecretKey key keys.get(version); if (key null) { throw new IllegalStateException(未找到密钥版本: version); } try { return AesGcmCipher.decrypt(raw, key); } catch (Exception e) { throw new IllegalStateException(属性解密失败请检查密钥与密文是否匹配, e); } } }注意异常信息里不要带明文也不要带完整密文。带上密钥版本号就够了生产日志里泄露密文等于泄露了一半信息。3.3 EnvironmentPostProcessor 的注册和生效时机处理器本身很简单核心是遍历属性源、替换、保持顺序public class DecryptingEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { SecretResolver resolver SecretKeyLoader.load(environment); MutablePropertySources sources environment.getPropertySources(); ListString names new ArrayList(); for (PropertySource? ps : sources) { names.add(ps.getName()); } PredicateString filter name - name.startsWith(app.secret.) || name.startsWith(spring.datasource.); for (String name : names) { PropertySource? original sources.get(name); if (original null || original instanceof DecryptingPropertySource) { continue; } sources.replace(name, new DecryptingPropertySource(name, original, resolver, filter)); } } Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; } }getOrder()返回LOWEST_PRECEDENCE是有讲究的。Spring Boot 2.4 之后配置文件通过ConfigDataEnvironmentPostProcessor加载它的 order 很靠前。如果你的处理器 order 太小执行时application.yml还没进属性源列表就漏掉了。设成最低优先级能保证所有配置文件都加载完之后再统一包装。迭代时要先收集名字快照不能在遍历MutablePropertySources的同时调用replace会抛ConcurrentModificationException。这个坑我在第一次写的时候踩过启动直接挂掉。SecretKeyLoader负责从环境变量或外部密钥文件读取主密钥。这里要强调一点密钥绝不能和密文放在同一个属性源里。密文在启动参数、密钥在环境变量这是最低要求再好一点是密钥从挂载的 secret 文件读只有特定用户可读。3.4 Spring Boot 3.x 的注册差异与兼容写法Spring Boot 2.x 的注册文件是META-INF/spring.factoriesorg.springframework.boot.env.EnvironmentPostProcessor\ com.example.config.DecryptingEnvironmentPostProcessorSpring Boot 3.x 改成了META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports内容直接写类名一行一个com.example.config.DecryptingEnvironmentPostProcessor如果项目要同时兼容 2.x 和 3.x两个文件都放内容各写各的格式。Spring Boot 3 会优先读 imports 文件找不到再回退到spring.factories放两份不会冲突。另外 3.x 对spring.factories的很多扩展点都做了迁移如果你还依赖其他扩展点建议一次性检查一遍别只改了这一处。3.5 让 Value、ConfigurationProperties、Environment 三条读取路径都走通包装完之后要验证三条路径是不是都拿到明文。Value走的是PropertySourcesPlaceholderConfigurer它读的是Environment的propertySources包装后的源直接生效没问题。ConfigurationProperties走的是ConfigurationPropertySources这是一个在环境准备末尾 attach 上去的视图。因为它在 attach 时持有的是MutablePropertySources的引用遍历是动态的所以后包装也生效。但要注意 attach 的时机在你的处理器之后——Ordered.LOWEST_PRECEDENCE恰好能保证这一点。environment.getProperty(key)直接遍历propertySources同样生效。有一个例外是PropertySource注解引入的属性源。它的加载时机在ConfigurationClassPostProcessor阶段晚于EnvironmentPostProcessor所以不会被包装到。如果这个源里也有加密值需要单独处理比如在BeanFactoryPostProcessor里再包一次或者干脆把加密值挪到application.yml里。4. 从加密一个值到项目跑起来的完整实操4.1 生成 256 位密钥并加密目标值密钥生成用 KeyGenerator 就行别自己拼字节数组KeyGenerator generator KeyGenerator.getInstance(AES); generator.init(256, new SecureRandom()); SecretKey key generator.generateKey(); String base64Key Base64.getEncoder().encodeToString(key.getEncoded()); System.out.println(主密钥: base64Key);256 位 AES 在 JDK 8u161 之后默认就支持不需要额外装策略文件。如果你的运行环境是很老的 JDK 8 小版本跑的时候会抛InvalidKeyException: Illegal key size那就得升级 JDK 或者装无限强度策略文件。这个报错信息很明确一看就知道原因。拿到密钥之后加密目标值SecretKey key new SecretKeySpec(Base64.getDecoder().decode(base64Key), AES); String encrypted AesGcmCipher.encrypt(Pssw0rd-2024, key); System.out.println(encrypted);输出大概长这样enc:v1:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx长度 60 到 80 个字符之间具体取决于明文长度。4.2 启动脚本里注入系统属性的几种写法Linux 下用-D传参java -Dapp.secret.db-passwordenc:v1:xxxxx \ -Dspring.profiles.activeprod \ -jar demo.jar注意值里的特殊字符。虽然用了 URL 安全 Base64理论上只有字母数字和-_但前缀里有个冒号在 YAML 或某些脚本里冒号后面跟空格会被当成分隔符。所以建议整个值用单引号包起来别图省事。容器环境里更推荐用环境变量避免命令行参数被ps看到env: - name: APP_SECRET_DB_PASSWORD valueFrom: secretKeyRef: name: app-secret key: db-password环境变量名APP_SECRET_DB_PASSWORD会被 Spring Boot 的宽松绑定映射成app.secret.db-password所以你的代码里照常写Value(${app.secret.db-password})就行。这个映射规则是全大写、下划线转点、点转短横线。搞清楚这条规则很多环境变量明明设了却读不到的问题就迎刃而解了。如果值里必须带加密前缀环境变量这种方式反而更安全因为不需要经过 shell 解析。4.3 写个测试验证解密链路真的生效测试这块有个坑SpringBootTest会缓存ApplicationContext如果第一个测试类启动时系统属性还没设后面再设也不会重新加载。所以要么用DirtiesContext要么在BeforeAll里设置并确保测试类顺序。SpringBootTest DirtiesContext(classMode DirtiesContext.ClassMode.AFTER_CLASS) class SecretPropertyTest { static final String KEY 测试用密钥的Base64; BeforeAll static void setUp() throws Exception { SecretKey key new SecretKeySpec(Base64.getDecoder().decode(KEY), AES); System.setProperty(app.secret.db-password, AesGcmCipher.encrypt(Pssw0rd-2024, key)); System.setProperty(APP_SECRET_KEY, KEY); } AfterAll static void tearDown() { System.clearProperty(app.secret.db-password); System.clearProperty(APP_SECRET_KEY); } Value(${app.secret.db-password}) String dbPassword; Autowired Environment environment; Test void 系统属性应被解密读取() { assertThat(dbPassword).isEqualTo(Pssw0rd-2024); assertThat(environment.getProperty(app.secret.db-password)) .isEqualTo(Pssw0rd-2024); } }AfterAll里清理系统属性这一步别省。系统属性是 JVM 级别的测试跑完不清理会影响同一个 JVM 里的其他测试类症状是单跑通过、全跑失败特别难查。4.4 长度、性能与缓存的实际测算密文长度可以精确算出来。明文n字节GCM 加密后是n 16字节认证标签加上 12 字节 IV总共n 28字节。Base64 URL 无填充编码后的长度是ceil((n 28) * 4 / 3)再减去填充再加上 7 个字符的前缀。拿一个 12 字符的口令举例12 28 40字节Base64 后约 54 字符加前缀总共 61 字符。这个长度对启动参数和环境变量来说毫无压力。性能方面AES-GCM 在现代 CPU 上有硬件加速指令单次加解密在微秒级。真正影响启动时间的是属性被读取的次数——一个中等规模的应用启动时属性读取可能上万次。所以缓存是必须的用ConcurrentHashMap以密文为 key 缓存明文命中率接近 100%。我实测过一个有 8 个加密属性的项目加了缓存之后解密耗时从 40 毫秒降到 3 毫秒以内。要提醒一句缓存里存的是明文内存 dump 能看到。如果对内存安全有极高要求可以考虑用char[]并在用完后清零但这会把代码复杂度拉高不少一般业务系统不必做到这个程度。5. 踩坑实录那些配置看着对但就是不生效的情况5.1 值被配置文件覆盖的排查顺序最常见的问题是系统属性设了但读出来还是配置文件里的值。排查顺序我一般是这样的第一步看 key 是否完全一致。系统属性里的点号、短横线、大小写都有讲究。app.secret.dbPassword和app.secret.db-password在宽松绑定下是同一个但在environment.getProperty里是两个不同的 key。用Value时尤其容易搞混。第二步看属性源顺序有没有被人为改过。有些项目启动时会调用environment.getPropertySources().addFirst(...)塞自定义源把系统属性挤下去了。这种情况在引入了某些配置中心客户端的项目里很常见。第三步看是不是加密前缀没匹配上。比如你的密文是ENC(xxx)格式但处理器只认enc:v1:那就原样返回了原始字符串看着像没解密。第四步看处理器有没有真的被注册。spring.factories里类名写错一个字母就静默不加载不报任何错。可以在处理器里打一行 info 日志启动时确认一下。5.2 解密异常与乱码的定位方法解密报错无非几类。AEADBadTagException说明密钥不对或者密文被改过这是 GCM 的认证失败属于最明确的一类。IllegalBlockSizeException通常是密文被截断比如传输过程中丢了字符。IllegalArgumentException: Illegal base64 character说明 Base64 解码失败基本可以断定是特殊字符被转义了。乱码问题多数出在编码上。加密时用 UTF-8 取字节解密时也必须用 UTF-8 还原中间任何一步用了平台默认编码都可能出问题。特别是在 Windows 上开发、Linux 上部署的场景默认编码不一样很容易踩。还有一种隐蔽情况属性值本身就是空字符串。System.setProperty(key, )设进去之后environment.getProperty返回的是空串而不是 null某些框架会把空串当成未配置处理走了默认值分支。如果加密后的值恰好解出空串行为会很怪。建议在解密之后加一句判空把空串转成 null 或者直接抛异常。5.3 常见问题速查表现象可能原因处理方式读到的是密文原文处理器未注册或 order 太靠前检查注册文件order 设 LOWEST_PRECEDENCE读到的是配置文件的值属性源被 addFirst 覆盖检查是否有自定义源插到最前面启动报 InvalidKeyExceptionJDK 版本低不支持 256 位升级 JDK 或装无限强度策略报 AEADBadTagException密钥不匹配或密文损坏核对密钥版本检查传输过程报 Illegal base64 character密文中特殊字符被转义改用 URL 安全 Base64ConfigurationProperties 绑不上包装时丢了 Enumerable包装类继承 EnumerablePropertySource测试单跑通过全跑失败系统属性污染AfterAll 里清理系统属性环境变量设了但读不到命名映射规则不匹配全大写、下划线分词5.4 密钥管理和日志里的安全红线有几个红线是必须守住的。密钥不能出现在代码仓库里包括测试代码。测试用的密钥要单独生成只用于测试环境而且要明确标注仅测试使用。密钥不能和密文在同一个属性源。如果两部分都在application.yml里那加密等于没做任何一个能看配置文件的人都能解密。日志里不能打印解密后的值。很多框架在 debug 级别会打印属性绑定的过程如果你的属性名比较敏感比如带password要确认这些日志不会上线。异常堆栈里不能带明文。Cipher.doFinal抛异常时不会带明文但如果你自己包装异常时把原值拼进 message那就漏了。前面给的SecretResolver里我只带了密钥版本就是这个考虑。还有一点容易被忽略actuator 的/env和/configprops端点默认会做脱敏脱敏规则是匹配password、secret、key、token这类关键词。如果你的属性名叫app.datasource.credential不在这份名单里就会被原样展示。这个名单可以自定义但更稳妥的做法是直接限制这些端点的访问权限。6. 上线前的加固清单6.1 actuator 端点的脱敏处理Spring Boot 的脱敏靠SanitizingFunction实现2.6 之后可以自定义。默认规则只覆盖常见关键词业务自定义的属性名很容易漏。Bean public SanitizingFunction customSanitizer() { return property - { String name property.getName().toLowerCase(); if (name.contains(credential) || name.contains(passwd) || name.contains(private)) { return property.withValue(******); } return property; }; }这段代码注册到容器后/env端点里匹配到的值都会显示成星号。注意这只是展示层面的保护属性值在内存里仍然是明文别把它当成加密手段。更彻底的做法是把 actuator 端点限制在内网访问或者干脆在生产环境只暴露health和info。配置项management.endpoints.web.exposure.include设成health,info就够用了。6.2 密钥轮换与多版本密钥的共存设计密钥轮换是安全实践里的常规操作但在配置加密这个场景下很容易做砸。核心思路是解密时支持多个版本加密时只用最新版本。前面给的enc:v1:前缀就是为此设计的。轮换时先生成 v2 密钥把SecretResolver的 keys 映射表里同时放 v1 和 v2重新加密所有敏感值并替换成enc:v2:前缀确认没有 v1 的密文残留之后再把 v1 从映射表里移除。这个过程可以分多次发布完成不需要一次性切换。第一版发布只加 v2 密钥的支持密文还是 v1第二版发布把密文换成 v2第三版发布移除 v1 密钥。每次发布都可回滚风险可控。要注意的是如果密钥来自外部服务轮换时要考虑服务的可用性。密钥服务挂了会导致应用完全起不来所以本地最好有一份降级密钥或者把密钥缓存到本地文件。6.3 测试环境与生产环境的隔离测试环境的密钥和生产环境必须完全不同这是基本要求。但实际项目里经常出现测试密钥硬编码在代码里、生产环境复用的情况。我的做法是把密钥来源做成可配置的通过app.secret.key-source指定可选值是env、file、vault。测试环境用env配合 CI 的变量注入生产环境用file挂载只读的密钥文件。这样同一份代码在不同环境走不同路径不需要改代码。同时要在启动时加一条校验如果检测到当前是生产 Profile 但密钥来源是env且密钥长度不足直接启动失败。宁可起不来也别带着弱密钥跑起来。另外测试环境的数据最好也是脱敏的。有些团队为了图省事把生产数据导到测试库密码还是同一套这种时候加密配置就形同虚设了。我个人在实际操作中的体会是配置加密这件事真正的难点不在算法而在让所有人都按同一套规则来。代码里写得再完善运维在启动脚本里直接写明文整条链路就破了。所以落地的时候除了技术方案最好再配一条启动校验检测到关键属性没有加密前缀时打一条 WARN 日志慢慢把习惯纠正过来。踩过几次坑之后我发现日志比文档管用得多。