DEV Community

Cover image for Stop Putting API Keys in .env Files: Use Your OS Keychain Instead
Dawid Makowski
Dawid Makowski

Posted on

Stop Putting API Keys in .env Files: Use Your OS Keychain Instead

Quick question: how many .env files do you have on your machine right now with a live OPENAI_API_KEY sitting in them in plain text? Be honest.

I had more than I want to admit. One per project, plus a few in ~/Downloads from when I was testing something, plus a couple in .bash_history from that one time I ran export OPENAI_API_KEY=sk-... directly because I was in a hurry.

Every one of those is a key sitting unencrypted on disk, waiting to be grep-ed by the wrong script, synced to the wrong backup, or pasted into the wrong screen share.

There is a better way, and it has been built into your operating system the whole time. Every major OS ships a secure credential store: macOS has Keychain, Linux has the Secret Service API, Windows has Credential Manager. This post shows you how to put your AI API keys (Gemini, OpenAI, Anthropic, xAI, DeepSeek, whatever) in there, and load them into your shell automatically, so:

  • Keys are never in a plain-text file.
  • Keys never show up in your shell history.
  • You rotate in one place.
  • The config file that loads them contains only key names, never values, so it is safe to commit to your dotfiles repo.

TL;DR

Store secrets in the OS keychain with the native CLI, then drop a small loader script that reads them into environment variables on shell startup. macOS uses security, Linux uses secret-tool, Windows uses Credential Manager via PowerShell. Skip to your platform below.

The keys I will use as examples throughout:

GEMINI_API_KEY
OPENAI_API_KEY
ANTHROPIC_API_KEY
XAI_API_KEY
DEEPSEEK_API_KEY
Enter fullscreen mode Exit fullscreen mode

Swap in whatever you actually use.

macOS

macOS gives you the security CLI, which talks to the Keychain you already use for wifi passwords and Safari logins.

Store a key

Run once per key. The -w flag with no value makes it prompt you to type the secret interactively, so it never lands in your shell history.

security add-generic-password -a "$USER" -s "GEMINI_API_KEY" -w
security add-generic-password -a "$USER" -s "OPENAI_API_KEY" -w
security add-generic-password -a "$USER" -s "ANTHROPIC_API_KEY" -w
security add-generic-password -a "$USER" -s "XAI_API_KEY" -w
security add-generic-password -a "$USER" -s "DEEPSEEK_API_KEY" -w
Enter fullscreen mode Exit fullscreen mode
  • -a "$USER" is the account (your login name)
  • -s "..." is the service name (the label you look it up by)
  • -w means prompt for the secret interactively

If a key already exists you will get an error. Delete it first (below), then re-add.

Read, update, delete

# Read
security find-generic-password -a "$USER" -s "GEMINI_API_KEY" -w

# Update (delete then re-add)
security delete-generic-password -a "$USER" -s "GEMINI_API_KEY"
security add-generic-password -a "$USER" -s "GEMINI_API_KEY" -w

# Delete
security delete-generic-password -a "$USER" -s "GEMINI_API_KEY"
Enter fullscreen mode Exit fullscreen mode

Load into your shell

Create ~/.api-keys.sh. Notice it contains only names, no secrets:

cat > ~/.api-keys.sh <<'EOF'
#!/usr/bin/env bash
# Loads API keys from macOS Keychain into environment variables.
keychain_load() {
    local var="$1" val
    val=$(security find-generic-password -a "$USER" -s "$var" -w 2>/dev/null)
    [[ -n "$val" ]] && export "$var=$val"
}
keychain_load GEMINI_API_KEY
keychain_load OPENAI_API_KEY
keychain_load ANTHROPIC_API_KEY
keychain_load XAI_API_KEY
keychain_load DEEPSEEK_API_KEY
EOF
Enter fullscreen mode Exit fullscreen mode

Source it from ~/.bash_profile (or ~/.zshrc if you are on zsh, which is the macOS default):

echo '[[ -f ~/.api-keys.sh ]] && source ~/.api-keys.sh' >> ~/.zshrc
Enter fullscreen mode Exit fullscreen mode

First run gotcha

The first time your shell reads a key, macOS pops up a dialog asking for permission to access the Keychain item. Click Always Allow so it stops asking. This is a deliberate trade-off: convenience in exchange for any process running as you being able to read these keys. For a dev laptop, that is a reasonable line to draw.

Linux

Linux uses the Secret Service API through libsecret, which plugs into GNOME Keyring, KWallet, or KeePassXC. Most desktop distros already have a keyring daemon running.

Install the tooling

# Debian / Ubuntu
sudo apt install libsecret-tools

# Fedora / RHEL
sudo dnf install libsecret

# Arch
sudo pacman -S libsecret
Enter fullscreen mode Exit fullscreen mode

Store a key

secret-tool store reads the secret from a prompt, so it stays out of your history. The --label is just a human-readable description; the service and key attributes are how you look it up later.

secret-tool store --label="GEMINI_API_KEY" service api_keys key GEMINI_API_KEY
secret-tool store --label="OPENAI_API_KEY" service api_keys key OPENAI_API_KEY
secret-tool store --label="ANTHROPIC_API_KEY" service api_keys key ANTHROPIC_API_KEY
secret-tool store --label="XAI_API_KEY" service api_keys key XAI_API_KEY
secret-tool store --label="DEEPSEEK_API_KEY" service api_keys key DEEPSEEK_API_KEY
Enter fullscreen mode Exit fullscreen mode

Read, update, delete

# Read
secret-tool lookup service api_keys key GEMINI_API_KEY

# Update (store overwrites a matching entry, so just store again)
secret-tool store --label="GEMINI_API_KEY" service api_keys key GEMINI_API_KEY

# Delete
secret-tool clear service api_keys key GEMINI_API_KEY
Enter fullscreen mode Exit fullscreen mode

Load into your shell

Create ~/.api-keys.sh:

cat > ~/.api-keys.sh <<'EOF'
#!/usr/bin/env bash
# Loads API keys from the Secret Service (libsecret) into environment variables.
keychain_load() {
    local var="$1" val
    val=$(secret-tool lookup service api_keys key "$var" 2>/dev/null)
    [[ -n "$val" ]] && export "$var=$val"
}
keychain_load GEMINI_API_KEY
keychain_load OPENAI_API_KEY
keychain_load ANTHROPIC_API_KEY
keychain_load XAI_API_KEY
keychain_load DEEPSEEK_API_KEY
EOF
Enter fullscreen mode Exit fullscreen mode

Source it from ~/.bashrc (or ~/.zshrc):

echo '[[ -f ~/.api-keys.sh ]] && source ~/.api-keys.sh' >> ~/.bashrc
Enter fullscreen mode Exit fullscreen mode

Headless servers

On a server with no graphical session there is usually no keyring daemon, so this approach does not apply cleanly. On servers, reach for pass (the GPG-backed Unix password manager) or a proper secrets manager. More on that at the end.

Windows

Windows has the built-in Credential Manager. PowerShell is the cleanest way to drive it.

Store a key

Save this helper as Set-ApiKey.ps1. It prompts for the value with Read-Host -AsSecureString so the secret never echoes to the terminal or history:

param([Parameter(Mandatory)][string]$Name)
$secret = Read-Host -AsSecureString "Enter value for $Name"
$plain  = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
            [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret))
cmdkey /generic:$Name /user:apikey /pass:$plain | Out-Null
Remove-Variable plain, secret
Write-Host "Stored $Name in Credential Manager"
Enter fullscreen mode Exit fullscreen mode

Then run it for each key:

.\Set-ApiKey.ps1 GEMINI_API_KEY
.\Set-ApiKey.ps1 OPENAI_API_KEY
.\Set-ApiKey.ps1 ANTHROPIC_API_KEY
.\Set-ApiKey.ps1 XAI_API_KEY
.\Set-ApiKey.ps1 DEEPSEEK_API_KEY
Enter fullscreen mode Exit fullscreen mode

Read and delete

cmdkey deliberately does not hand back secret values, so to read them programmatically use the CredentialManager module:

Install-Module CredentialManager -Scope CurrentUser   # one time

$cred = Get-StoredCredential -Target GEMINI_API_KEY
$cred.GetNetworkCredential().Password
Enter fullscreen mode Exit fullscreen mode
# Delete
cmdkey /delete:GEMINI_API_KEY
Enter fullscreen mode Exit fullscreen mode

Load into your shell

Add this to your PowerShell profile (run notepad $PROFILE to open it, create it if missing):

# Requires: Install-Module CredentialManager -Scope CurrentUser
$apiKeys = @(
    'GEMINI_API_KEY','OPENAI_API_KEY','ANTHROPIC_API_KEY',
    'XAI_API_KEY','DEEPSEEK_API_KEY'
)
foreach ($k in $apiKeys) {
    $cred = Get-StoredCredential -Target $k -ErrorAction SilentlyContinue
    if ($cred) {
        [Environment]::SetEnvironmentVariable(
            $k, $cred.GetNetworkCredential().Password, 'Process')
    }
}
Enter fullscreen mode Exit fullscreen mode

WSL note

If you work in WSL, treat it as Linux: install libsecret-tools and follow the Linux section. WSL does not automatically share the Windows Credential Manager.

Using the keys

Once loaded, they are just environment variables. Anything that reads the environment picks them up:

echo "${GEMINI_API_KEY:0:6}..."     # show only the first 6 chars to verify
python script.py                     # os.environ["OPENAI_API_KEY"]
node app.js                          # process.env.OPENAI_API_KEY
Enter fullscreen mode Exit fullscreen mode

A note for Laravel devs

Laravel reads .env, but vlucas/phpdotenv does not overwrite variables that already exist in the environment. So php artisan commands run from your terminal inherit your shell environment, and env('GEMINI_API_KEY') works without the key being in .env at all, as long as you:

  1. Leave the key out of .env.
  2. Reference it via env('GEMINI_API_KEY') in config/services.php.
  3. Avoid php artisan config:cache on local dev (it freezes values; run php artisan config:clear after rotating).

The catch: processes that do not inherit your shell (Herd PHP-FPM, supervisor-managed queue workers, Docker) will not see these keys. For those you either set the env var in that tool's own config, or generate the .env entries from the keychain with a small script. Either way, keep .env in .gitignore.

Verify everything loaded

macOS and Linux:

for k in GEMINI OPENAI ANTHROPIC XAI DEEPSEEK; do
    var="${k}_API_KEY"
    if [[ -n "${!var}" ]]; then
        echo "$var: ${!var:0:6}... OK"
    else
        echo "$var: MISSING"
    fi
done
Enter fullscreen mode Exit fullscreen mode

Windows PowerShell:

'GEMINI_API_KEY','OPENAI_API_KEY','ANTHROPIC_API_KEY','XAI_API_KEY','DEEPSEEK_API_KEY' | ForEach-Object {
    $val = [Environment]::GetEnvironmentVariable($_, 'Process')
    if ($val) { "{0}: {1}... OK" -f $_, $val.Substring(0,6) }
    else      { "$_`: MISSING" }
}
Enter fullscreen mode Exit fullscreen mode

Where this approach ends

Be clear about what this buys you and what it does not.

The keychain protects keys at rest. Once a key is loaded into an environment variable, any process running as your user can read it. That is fine for a developer laptop. It is not fine for production servers or CI pipelines, where you should use a real secrets manager: HashiCorp Vault, AWS Secrets Manager, Doppler, or your CI provider's built-in secrets. This pattern is for your laptop, full stop.

A few habits that keep it clean:

  • Rotate keys in the keychain, not in files. After rotating, open a new terminal so the loader picks up the new value.
  • Never paste a key directly into a command. Always use the interactive prompt. A key typed inline lives forever in your shell history.
  • The ~/.api-keys.sh loader holds only names, so commit it to your dotfiles. The secrets stay in each machine's local keychain.

That is it. Twenty minutes of setup, and your API keys stop leaking into files you forget about. Your future self, the one who almost force-pushed a key to a public repo, says thanks.


If you found this useful, I write about practical engineering and dev tooling. Drop a comment with how you handle local secrets, I am always curious what other people land on.

Top comments (0)