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.
spring.datasource.password=${DB_PASSWORD}
spring.config.import=optional:configtree:/etc/orders-api/secrets/
management.endpoint.env.show-values=neverA 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
envandconfigpropsendpoints mask values by default. Leaveshow-valuesatneverin production, or usewhen-authorizedbehind 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. OverridetoString()to mask it. - Never log the whole
Environmentor 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
# 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=neverWhen 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.
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-secretsMasking 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.
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.
# .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