From .env to Spring Boot Secrets from Your OS Keyring

Every Spring Boot project I touch eventually grows a .env, an application-local.yml, or some secrets.properties that I'm told to "just not commit". It's convenient: one file, all your local keys, sourced in seconds.
But here's the catch: that file is plaintext, and it sits on disk. Anything running as you can read it — a misbehaving build plugin, a backup daemon, or, increasingly, a coding agent you let loose in your repo. Putting a sensitive API key or a production-adjacent DB password in there is something I'm not comfortable doing on my daily driver.
The Problem with the Local Secrets File
The .env approach is tempting because it solves the problem instantly. You drop a key in a file, add the file to .gitignore, and move on.
The issues show up later:
- It's plaintext on disk. No encryption, no access control beyond file permissions.
- It's easy to leak. A stray
git add -A, a.gitignorethat doesn't match, a copied folder, a screen-shared terminal. - Everything running as you can read it. Your OS doesn't distinguish between you and a tool you ran. A hallucinating coding agent with shell access can
catit just as easily as you can.
Your operating system already ships a better place for this: a credential store. macOS has the Keychain, Windows has the Credential Manager, Linux has the Secret Service (GNOME Keyring and friends). They're encrypted, unlocked per-user, and meant exactly for this.
So the question I kept asking was: why is my secret in a file at all, when the OS already has somewhere safe to keep it?
spring-keyring-config
That question is what spring-keyring-config answers.
A Spring Boot ConfigData provider that resolves
${keyring@…}references straight from the host OS credential store — so no plaintext secret file sits on disk for a tool, backup, or coding agent to read.
It plugs into Spring Boot's ConfigData mechanism as a reference-only property source. It doesn't dump your whole keyring into the environment; it only answers placeholders that explicitly ask for a keyring@ reference, and it reads them fresh each time — no caching, no copy living in memory longer than it needs to.
Under the hood it just talks to the native tools you already have:
| OS | Backend | Via |
|---|---|---|
| macOS | Keychain | security CLI |
| Linux | Secret Service (GNOME Keyring, etc.) | secret-tool |
| Windows | Credential Manager | cmdkey |
Requirements are modest: Java 17+ and Spring Boot 3.2+. It's licensed Apache-2.0.
Putting a Secret in the Keyring
First, store the secret once, using the tool your OS already provides. No application code involved.
# macOS
security add-generic-password -s sensitive-api-key -w
# Linux
secret-tool store --label="sensitive api key" service sensitive-api-key
:: Windows
cmdkey /generic:sensitive-api-key /user:%USERNAME% /pass:<secret>
The secret now lives in the encrypted store, gated behind your login session. There's nothing to .gitignore.
Wiring It into Spring Boot
Add the dependency:
implementation("com.sugarfreebytes:spring-keyring-config:0.0.1")
// Windows only
runtimeOnly("net.java.dev.jna:jna-platform:5.14.0")
Turn the provider on with a single config import:
spring:
config:
import: "keyring:"
Then reference your secrets the same way you reference any other property — except the value comes from the keyring, not from the file:
api-key: "${keyring@sensitive-api-key}" # service only
db-password: "${keyring@db-reporting/app-user}" # service/account
The service/account form maps onto the two-part identity those credential stores use, so you can keep several accounts under one logical service. And if you want a harmless fallback for a value you don't care about locally, defaults work as usual:
db-password: "${keyring@db-pass:dev-only}"
That's the whole integration. Your application.yml can be committed without a second thought — it contains references, not secrets.
Failing Loud, On Purpose
One design choice worth calling out: the library is deliberately strict, because a secret that silently resolves to an empty string is worse than one that fails.
- A missing entry with no default throws
KeyringMissingSecretExceptioninstead of quietly becoming"". - A store that's unreachable, locked, or simply not there — a missing CLI, an unsupported OS — throws
KeyringAccessExceptioneven when a default is present, so it can't masquerade as "use the fallback". - Secrets that contain
${…}are rejected (KeyringInterpolatableSecretException) rather than being re-interpolated into something you didn't intend.
You find out at startup that something is wrong, not three layers deep into a request.
Where This Fits (and Where It Doesn't)
To be clear about the trade-off: this is a local-development tool. CI runners, containers, and headless or shared boxes typically have no unlocked per-user keyring to talk to — and that's by design. For those environments you still want a real secrets manager (Vault, cloud KMS, the platform's injected env vars).
What it replaces is the messy middle: the plaintext .env on your own machine. For that one job — keeping the keys you develop with out of a file and inside the vault your OS already runs — it's a clean fit.
The code is on GitHub at sugarfreebytes/spring-keyring-config, with 0.0.1 already on Maven Central. It's early days, Apache-2.0, and feedback is welcome.
