Keeping Secrets out of Code
Load database passwords and API keys from the environment or mounted files, fail fast when they are missing, and stop them leaking into logs and Git.
String password = System.getenv("DB_PASSWORD");
if (password == null || password.isBlank())
throw new IllegalStateException("DB_PASSWORD is not set");
String key = Files.readString(Path.of("/run/secrets/api_key")).strip();A secret is anything that grants access: database passwords, API keys, signing keys for JWTs and webhooks, OAuth client secrets. None of them belongs in source code, in application.properties committed to Git, or in a Docker image.
Why hard-coding is worse than it looks
- Git remembers. Deleting the line in a later commit leaves the secret in history, in every clone and fork. Once a secret is pushed, treat it as leaked and rotate it.
- Class files are readable. String constants are stored as plain text in the compiled class, so anyone with the JAR can run
stringsor a decompiler over it. - One value for every environment. A hard-coded key means development and production share credentials, and changing it needs a rebuild and redeploy.
Where secrets come from instead
- Environment variables, read with
System.getenv("DB_PASSWORD"). Simple and supported everywhere, but visible to anything that can inspect the process environment. - Mounted files. Kubernetes Secrets and Docker secrets can appear as files, for example under
/run/secrets/. Many images support a_FILEvariant of a variable that points at such a file. Strip the trailing newline when reading. - A secret manager such as HashiCorp Vault or a cloud provider's service, which adds auditing and rotation. Your code often still sees an environment variable or file in the end.
Read secrets once at startup into a configuration object, and fail fast if a required one is missing: refusing to start with a clear message naming the variable is far better than a NullPointerException on the first request at 3 a.m. Pass that object in rather than calling System.getenv all over the code; it also makes the code testable with a plain map.
Not leaking them at runtime
- Records print everything. A record's generated
toStringincludes every component, so logging a config record logs the password. OverridetoStringto mask it. - System properties on the command line, such as
-Ddb.password=..., show up in process listings and injcmdoutput. Prefer environment variables or files. - Error messages and logs should name a missing or invalid setting, never echo its value. Log a short hash or the last four characters if you need to tell keys apart.
For local development, keep values in a .env file listed in .gitignore, commit a .env.example with fake values, and turn on secret scanning in your Git host so a slip is caught at push time.
Example
When to use it
- Read the database password from a Kubernetes Secret mounted as a file and refuse to start if it is absent.
- Mask the API key in a configuration record's toString so startup logging never prints it.
- Keep a committed .env.example with fake values while the real .env stays out of Git for local development.
More examples
The default record toString leaks
Overriding toString changes only what logs and debuggers show; the accessor still returns the full key for the code that needs it. Showing the last four characters is enough to tell which key is configured.
record LeakyKey(String provider, String apiKey) {}
record SafeKey(String provider, String apiKey) {
@Override
public String toString() {
String tail = apiKey.length() > 4 ? apiKey.substring(apiKey.length() - 4) : "";
return "SafeKey[provider=" + provider + ", apiKey=****" + tail + "]";
}
}
void main() {
String key = "demo_key_51Hx9QpLr2Za0000";
IO.println("log: " + new LeakyKey("payments", key));
IO.println("log: " + new SafeKey("payments", key));
IO.println("still usable: " + new SafeKey("payments", key).apiKey().equals(key));
}
// Output:
// log: LeakyKey[provider=payments, apiKey=demo_key_51Hx9QpLr2Za0000]
// log: SafeKey[provider=payments, apiKey=****0000]
// still usable: trueFailing fast on every missing setting
Reporting all missing names at once saves a redeploy for each one. A real service would exit with a non-zero status here so the orchestrator marks the deployment as failed.
List<String> missing(List<String> names) {
return names.stream()
.filter(name -> {
String value = System.getenv(name);
return value == null || value.isBlank();
})
.toList();
}
void main() {
List<String> required = List.of("SHOPDEMO_DB_PASSWORD", "SHOPDEMO_PAYMENTS_KEY", "SHOPDEMO_JWT_KEY");
List<String> absent = missing(required);
if (!absent.isEmpty()) {
// Name the variables, never print values.
IO.println("refusing to start, missing: " + String.join(", ", absent));
return;
}
IO.println("all secrets present");
}
// Output:
// refusing to start, missing: SHOPDEMO_DB_PASSWORD, SHOPDEMO_PAYMENTS_KEY, SHOPDEMO_JWT_KEYThe trailing newline in secret files
A key with a hidden newline produces 401 errors that look like a wrong key. Strip values read from files, and from environment variables set by scripts that might add whitespace.
void main() throws IOException {
String expected = "demo-api-key-123";
Path file = Files.createTempFile("api_key", ".txt");
try {
Files.writeString(file, expected + "\n"); // what echo or most editors produce
String raw = Files.readString(file);
String clean = raw.strip();
IO.println("raw length " + raw.length() + ", matches: " + raw.equals(expected));
IO.println("stripped length " + clean.length() + ", matches: " + clean.equals(expected));
} finally {
Files.deleteIfExists(file);
}
}
// Output:
// raw length 17, matches: false
// stripped length 16, matches: true
Discussion