Keeping Secrets out of Configuration

Keep passwords and API keys out of the repository and logs with placeholders, environment variables, mounted secret files and masked actuator output.

Syntaxspring.datasource.password=${DB_PASSWORD} spring.config.import=optional:configtree:/etc/orders-api/secrets/ management.endpoint.env.show-values=never

A secret is any value that grants access: database passwords, API keys, signing keys, SMTP credentials. Once one is committed to Git it is in every clone and every backup, and deleting the line does not remove it from history. The only real fix is to rotate it. So secrets never go in application.yaml, not even in application-prod.yaml.

Placeholders in the file, values from outside

Keep the key in the file so readers can see the application needs it, and supply the value at runtime:

spring.datasource.password=${DB_PASSWORD}

Know what happens when DB_PASSWORD is not set. A placeholder in @Value fails startup, but keys bound to @ConfigurationProperties classes, which is how Boot reads spring.datasource.*, keep the unresolved text: the pool tries to log in with the literal password ${DB_PASSWORD} and you get an authentication error from the database instead of a clear message. For your own validated keys, write ${PAYMENTS_API_KEY:} so an unset variable becomes an empty string that @NotBlank rejects at startup. You can also skip the file entirely and set SPRING_DATASOURCE_PASSWORD, but the placeholder documents the dependency.

Mounted secret files

Kubernetes and Docker can mount secrets as files, one file per value. spring.config.import=optional:configtree:/etc/orders-api/secrets/ turns each file in that folder into a property named after the file, so a file called spring.datasource.password sets that key. Files are safer than environment variables: variables are visible to anything that can list a process's environment, and they often end up in crash reports and debug output.

Secret managers

Vault, AWS Secrets Manager and similar services add rotation and auditing. They plug into the same spring.config.import mechanism through separate libraries, so application code still just reads properties. The rest of this lesson applies unchanged.

Do not leak them at runtime

  • Actuator's env and configprops endpoints mask values by default. Leave show-values at never in production, or use when-authorized behind proper security.
  • A record's generated toString() prints every component. Log a properties record with an API key in it and the key is in your log platform. Override toString() to mask it.
  • Never log the whole Environment or the raw request to a downstream API that carries a key in a header.

Local development

Developers still need values. Use obviously fake ones for local containers, and keep anything real in an untracked file such as .env imported with optional:file:.env[.properties], listed in .gitignore. Add a secret scanner to CI so an accidental commit fails the build instead of reaching the remote.

Example

Example · properties
# application.properties: names the secrets, never contains them
spring.datasource.url=jdbc:postgresql://orders-db.internal:5432/orders
spring.datasource.username=orders
spring.datasource.password=${DB_PASSWORD}

spring.mail.host=smtp.example.com
spring.mail.username=${SMTP_USERNAME}
spring.mail.password=${SMTP_PASSWORD}

# Empty default: an unset variable binds as "" and @NotBlank stops startup.
app.payments.api-key=${PAYMENTS_API_KEY:}

# Each file in /etc/orders-api/secrets becomes a property named after the file.
# optional: lets the app start locally where the folder does not exist.
spring.config.import=optional:configtree:/etc/orders-api/secrets/,optional:file:.env[.properties]

# Keep actuator from revealing values.
management.endpoint.env.show-values=never
management.endpoint.configprops.show-values=never

When to use it

  • Supplying the production database password through a Kubernetes Secret mounted as files instead of a value in application-prod.yaml.
  • Preventing a payments API key from appearing in logs when the properties record is printed during startup debugging.
  • Letting developers keep personal sandbox credentials in an ignored .env file while the repository contains only placeholders.

More examples

Mounting a Kubernetes Secret as files

Each key in the Secret becomes a file under `/etc/orders-api/secrets`, and the configtree import turns `DB_PASSWORD` into a property that resolves the `${DB_PASSWORD}` placeholder. The values here are placeholders: real ones are created in the cluster or by a secrets operator, never committed. Mounted files can be updated without rebuilding the image, though the application reads them at startup. Use a dedicated folder rather than `/run/secrets` or `/var/run/secrets`, where Kubernetes places the service account token; the configtree import reads subfolders too and would turn that token into a property.

Example · yaml
apiVersion: v1
kind: Secret
metadata:
  name: orders-api-secrets
type: Opaque
stringData:
  DB_PASSWORD: change-me-in-the-cluster
  PAYMENTS_API_KEY: test_key_not_real
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: orders-api
  template:
    metadata:
      labels:
        app: orders-api
    spec:
      containers:
        - name: api
          image: registry.example.com/orders-api:1.8.2
          volumeMounts:
            - name: secrets
              mountPath: /etc/orders-api/secrets
              readOnly: true
      volumes:
        - name: secrets
          secret:
            secretName: orders-api-secrets

Masking a secret in a properties record

`@NotBlank` makes a missing or empty key fail startup (with the `${PAYMENTS_API_KEY:}` mapping above, an unset variable arrives as an empty string), and the custom `toString()` means a debug log line or an exception message shows only the first four characters. Showing a short prefix helps people confirm which key is deployed without exposing it. Apply the same treatment to any object holding a token that might reach a log.

Example · java
import java.net.URI;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties("app.payments")
public record PaymentsProperties(@NotNull URI baseUrl, @NotBlank String apiKey) {

    // The generated toString() would print the key in full.
    @Override
    public String toString() {
        return "PaymentsProperties[baseUrl=" + baseUrl + ", apiKey=" + mask(apiKey) + "]";
    }

    private static String mask(String value) {
        if (value == null || value.length() < 8) {
            return "****";
        }
        return value.substring(0, 4) + "****";
    }
}

A local .env file that never gets committed

The `[.properties]` suffix in the import tells Boot to parse a file without an extension as a properties file. Because the import is `optional:`, servers without the file start normally and take their values from the environment or mounted secrets. `git check-ignore` confirms the rule works before the first commit, not after.

Example · bash
# .env is read by spring.config.import=optional:file:.env[.properties]
cat > .env <<'EOF'
DB_PASSWORD=local-dev-only
PAYMENTS_API_KEY=test_key_not_real
SMTP_USERNAME=dev
SMTP_PASSWORD=dev
EOF

# Make sure it can never be committed
echo ".env" >> .gitignore
git check-ignore -v .env      # prints the .gitignore rule that matches

./mvnw spring-boot:run

Discussion

  • Be the first to comment on this lesson.