Skip to content

Spring Boot 3.4.x: spring.config.import external secret loading is too late, unlike 3.3.x, causing startup errors #46473

@Raja-shekaran

Description

@Raja-shekaran

Problem Description:

After upgrading our Spring Boot application from version 3.3.x to 3.4.6, we're consistently encountering PropertyNotFoundException errors during application startup. Our application relies on spring.config.import with sm:// or sm@ prefixes to dynamically fetch secrets from a cloud Secret Manager (e.g., GCP Secret Manager) and inject them into the Spring Environment.

Observed Behavior in Spring Boot 3.4.6:

The application fails to start because certain components or beans attempt to access properties (e.g., spring.data.database.uri, spring.data.database.username, spring.data.database.password) that are expected to be resolved from sm:// or sm@ placeholders. However, these properties are not yet available in the Spring Environment at the time of access. This indicates that the spring.config.import mechanism for these external secrets is completing after or too late for some early bean initialization phases.

Expected Behavior in Spring Boot 3.3.x:

In Spring Boot 3.3.x, this setup worked reliably. The spring.config.import mechanism for sm:// or sm@ prefixes would consistently load all necessary secrets into the Spring Environment before any beans that depended on these secrets were initialized. No PropertyNotFoundException or similar startup failures were observed related to secret resolution timing.

Context / Suspected Change:

This change in behavior suggests a shift in the timing or order of property loading and spring.config.import processing relative to bean initialization between Spring Boot 3.3.x and 3.4.x.

Workaround Implemented:

To mitigate this issue and allow the application to start, we've implemented a custom EnvironmentPostProcessor to fetch and inject secrets very early in the Spring Boot startup process.

  1. Declare EnvironmentPostProcessor:
    src/main/resources/META-INF/spring.factories:

    org.springframework.boot.env.EnvironmentPostProcessor=\
    com.example.app.config.CustomSecretManagerEnvProcessor
  2. Implement CustomSecretManagerEnvProcessor:
    This class is ordered with @Order(Ordered.HIGHEST_PRECEDENCE + 11) to ensure early execution. It reads a mapping from application.yml, fetches the corresponding secrets from our Secret Manager, and then adds them directly to Spring's ConfigurableEnvironment using env.propertySources.addFirst().

    package com.example.app.config;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.env.EnvironmentPostProcessor;
    import org.springframework.core.Ordered;
    import org.springframework.core.annotation.Order;
    import org.springframework.core.env.ConfigurableEnvironment;
    import org.springframework.core.env.MapPropertySource;
    import java.util.HashMap;
    import java.util.Map;
    // Potentially import org.springframework.boot.env.YamlPropertySourceLoader;
    // Or similar to read the secrets.mapping if it's complex
    
    @Order(Ordered.HIGHEST_PRECEDENCE + 11) // Ensures very early execution
    public class CustomSecretManagerEnvProcessor implements EnvironmentPostProcessor {
    
        private static final String CUSTOM_PROPERTY_SOURCE_NAME = "custom-secret-properties";
        // private static final String YAML_SECRETS_MAPPING_PATH = "secrets.mapping"; // If reading from env
    
        @Override
        public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
            final Map<String, Object> loadedSecretProps = new HashMap<>();
    
            // MASKED: Logic to read 'secrets.mapping' from YAML
            // Example: Access properties like env.getProperty("secrets.mapping.secret-name")
            // MASKED: Logic to initialize Secret Manager client (e.g., GCP Secret Manager client)
            //         and fetch secrets based on the 'secrets.mapping' configuration.
            // Example structure:
            // List<Map<String, String>> mappings = (List<Map<String, String>>) env.getProperty("secrets.mapping", List.class);
            // if (mappings != null) {
            //     SecretManagerClient client = initializeSecretManagerClient(); // Your client init
            //     for (Map<String, String> mapping : mappings) {
            //         String secretId = mapping.get("secret-name");
            //         String propertyKey = mapping.get("property-key");
            //         if (secretId != null && propertyKey != null) {
            //             String rawSecret = client.getSecret(secretId); // Your secret fetch logic
            //             loadedSecretProps.put(propertyKey, rawSecret);
            //         }
            //     }
            // }
    
            // Example hardcoded for demonstration (replace with actual fetching based on mapping)
            loadedSecretProps.put("masked.db.password", "fetchedPassword123");
            loadedSecretProps.put("masked.azure.client-id", "fetchedClientId");
    
            if (!loadedSecretProps.isEmpty()) {
                env.propertySources.addFirst(
                    new MapPropertySource(CUSTOM_PROPERTY_SOURCE_NAME, loadedSecretProps)
                );
            }
        }
    }
  3. Define Secret Mapping in application.yml:
    This section specifies which secrets the EnvironmentPostProcessor should fetch and map to Spring properties.

    # application.yml snippet
    spring:
      cloud:
        gcp:
          secretmanager:
            enabled: false # Optional: Disable default if it conflicts/duplicates
      # ... other configurations
    
    # This section drives the CustomSecretManagerEnvProcessor
    secrets:
      mapping:
        - secret-name: "your-gcp-secret-id-1" # Name in Secret Manager
          property-key: "masked.db.password" # Property key in Spring env
        - secret-name: "your-gcp-secret-id-2"
          property-key: "masked.azure.client-id"
        # ... more mappings
    
    # Example of consuming the properties after the EnvironmentPostProcessor has run
    app:
      datasource:
        password: ${masked.db.password}
      azure:
        clientId: ${masked.azure.client-id}

This workaround ensures that critical secrets are loaded and available in the Spring Environment at a very early stage, bypassing the timing issues experienced with spring.config.import in Spring Boot 3.4.x.

Question for the Spring Boot Team:

Is this change in the timing of spring.config.import for external secrets an intentional architectural shift in Spring Boot 3.4.x, or could it be an unintended regression? Understanding the intended behavior would help us determine if our EnvironmentPostProcessor workaround is the recommended approach for such scenarios or if there's a more idiomatic/built-in way to handle early secret loading in 3.4.x.

Example application.yml (original, problematic setup):

# application.yml
spring:
  config:
    import: sm://
  data:
    database:
      uri: ${sm://<PATH_TO_SECRET_FOR_URI>}
      username: ${sm://<PATH_TO_SECRET_FOR_USERNAME>}
      password: ${sm://<PATH_TO_SECRET_FOR_PASSWORD>}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions