All posts 🔐

macOS Keychain Tutorial for Developers — Store API Keys the Right Way

We spent three years storing API keys in .env files. Plaintext. No encryption. No auth. One afternoon, we ran find ~/dev -name ".env" and counted 47 of them. Same Stripe key copy-pasted across six projects. Same OpenAI token in repos we hadn't touched in months.

The whole time, there was a hardware-encrypted credential store sitting right there on our Macs. Built into the OS since 10.0. Backed by the Secure Enclave. Protected by Touch ID. We just never used it for developer secrets.

This tutorial covers how to actually use the macOS Keychain for API keys — from the native CLI to a workflow you'll stick with.

What the macOS Keychain actually is

It's not just a password manager. The Keychain is a system-level credential store with two distinct layers.

The login keychain is the legacy layer. A SQLite database at ~/Library/Keychains/login.keychain-db, encrypted with your macOS login password. It unlocks when you log in and stays open until you lock it or your Mac sleeps. Most CLI tools hit this one by default.

The Data Protection Keychain is the modern layer — available on every Mac with Apple Silicon or a T2 chip. It's backed by the Secure Enclave, a physically isolated coprocessor on the SoC with its own encrypted memory and cryptographic engine. Keys generated inside the Enclave never leave the chip. Not into RAM, not into swap, not into a crash dump.

When you store a secret with biometric access controls in the Data Protection Keychain, the Secure Enclave handles all encryption and decryption internally. It only releases the plaintext after a successful Touch ID match. No software exploit can pull the decryption key out — it exists only in silicon.

AES-256
hardware encryption
0
keys extractable from Secure Enclave
200ms
per Touch ID verification

Compare that to a .env file: plaintext on disk, readable by any process running as your user, zero authentication required. The Keychain isn't just better — it's a fundamentally different security model.

Tutorial: using the native security CLI

macOS ships with a built-in command-line tool called security for Keychain operations. Here's how it works for developer secrets.

Storing a secret

# Store an API key in the login keychain
$ security add-generic-password -s "myapp" -a "OPENAI_API_KEY" -w "sk-proj-abc123def456"

# -s = service name (like a category)
# -a = account name (the key name)
# -w = the secret value

Retrieving a secret

# Read the value back
$ security find-generic-password -s "myapp" -a "OPENAI_API_KEY" -w
sk-proj-abc123def456

Updating a secret

# This fails — you can't update in place
$ security add-generic-password -s "myapp" -a "OPENAI_API_KEY" -w "sk-proj-new-value"
security: SecKeychainItemCreateFromContent: The specified item already exists in the keychain.

# You have to delete first, then re-add
$ security delete-generic-password -s "myapp" -a "OPENAI_API_KEY"
$ security add-generic-password -s "myapp" -a "OPENAI_API_KEY" -w "sk-proj-new-value"

Using it in a script

# Load a Keychain secret into an environment variable
export OPENAI_API_KEY=$(security find-generic-password -s "myapp" -a "OPENAI_API_KEY" -w)

# Now use it normally
curl https://api.openai.com/v1/models \
  -H "Authorization: Bearer $OPENAI_API_KEY"

Listing secrets

# There's no clean list command. You get a dump.
$ security dump-keychain | grep -B3 "svce"
    "svce"="myapp"
    "acct"="OPENAI_API_KEY"
    ...

This works. Technically. But after a week of using it across multiple projects, the friction becomes obvious.

Five problems with the security CLI

The native tool has real limitations that make it impractical for daily work.

1. Secret values land in shell history. When you run security add-generic-password -w "sk_live_...", that value gets saved to ~/.zsh_history or ~/.bash_history. Permanently. Any process can read your shell history file.

# Your secret is now here forever
$ history | grep "add-generic-password"
  142  security add-generic-password -s "myapp" -a "STRIPE_KEY" -w "sk_live_4eC39HqLy..."

2. No Touch ID. The security CLI uses the login keychain by default, which authenticates with your macOS password — not Touch ID. Getting biometric auth requires the Data Protection Keychain with Swift code, not the CLI.

3. No update-in-place. You can't change a secret. Delete it, re-add it. Two commands every time you rotate a key.

4. No namespace hierarchy. With 30 secrets across 6 projects, everything's flat. The -s (service) and -a (account) fields give you two levels. That's not enough.

5. Raw values print to stdout. The -w flag prints the plaintext secret straight to your terminal. If you're sharing your screen, streaming, or have an AI agent reading your terminal output — the secret's exposed.

The shell history problem is worse than it sounds
Shell history files are plaintext, readable by any process, and backed up by Time Machine. If you've ever stored a secret using security add-generic-password -w, that value may exist in multiple backup snapshots across multiple drives. Run history -c to clear your current session, but the history file itself needs manual cleanup.

A developer-friendly alternative: NoxKey

We hit every one of these problems when we tried to move our secrets from .env files to the Keychain. The Keychain itself was right — the interface was wrong. So we built NoxKey as a thin wrapper around the same Keychain APIs, designed for how developers actually work.

Here's the same workflow, side by side:

Storing a secret

security CLI
# Value goes into shell history
security add-generic-password \
  -s "myapp" -a "STRIPE_KEY" \
  -w "sk_live_4eC39..."
NoxKey
# Copy value to clipboard, then click
# "Add Secret" in the menu bar app.
# (Agents call the bundled MCP server:
#  noxkey_set(account: "myorg/payments/STRIPE_KEY",
#             clipboard: true,
#             field_type: "api_key"))
# Nothing in shell history.

Retrieving a secret

security CLI
# Raw value printed to stdout
security find-generic-password \
  -s "myapp" -a "STRIPE_KEY" -w
sk_live_4eC39...
NoxKey
# Agent calls (MCP):
#   noxkey_get(account:
#     "myorg/payments/STRIPE_KEY")
# → returns: source '/tmp/...sh'
# Agent runs that one line in Bash.
# Touch ID → secret in $STRIPE_KEY,
# raw value never enters the terminal
# or the agent's context window.

Updating a secret

security CLI
# Two commands — delete then re-add
security delete-generic-password \
  -s "myapp" -a "STRIPE_KEY"
security add-generic-password \
  -s "myapp" -a "STRIPE_KEY" \
  -w "sk_live_NEW..."
NoxKey
# Open the secret in the menu bar app
# and paste the new value. Or re-run
# noxkey_set with the same account —
# it overwrites in place, one Touch ID.

Listing secrets

security CLI
# Unstructured dump
security dump-keychain | \
  grep -A4 "svce"
NoxKey
# Menu bar tree view, no values shown
# (agents call noxkey_show for the same).
# myorg/payments/STRIPE_KEY
# myorg/api/OPENAI_KEY
# myorg/db/PROD_URL  strict

NoxKey uses the Data Protection Keychain with .biometryCurrentSet access controls. Every access requires Touch ID. The org/project/KEY naming gives you a clean hierarchy across all your projects. And the agent handoff means the raw secret value never appears in your terminal, your shell history, or the agent's conversation context — it loads directly into your environment through an encrypted, self-deleting script.

Practical workflow: migrating from .env to Keychain

Here's the process we used to move 47 projects off .env files. Took an afternoon.

Step 1: Find your .env files

$ find ~/dev -name ".env" -not -path "*/node_modules/*" -not -path "*/.git/*"
/Users/you/dev/webapp/.env
/Users/you/dev/api-server/.env
/Users/you/dev/side-project/.env
...

Step 2: Install NoxKey

Install NoxKey from the Mac App Store.

Step 3: Import each .env file

Drag the .env file onto the NoxKey menu bar icon. A native review sheet opens with every key listed (values masked). Pick the org/project — for example myorg/webapp — confirm the keys you want, hit Import. One Touch ID writes the whole batch.

# If you'd rather drive it from an AI agent, the bundled MCP server
# exposes the same flow:
#   noxkey_scan(path: "/Users/you/dev/webapp",
#               suggested_org: "myorg",
#               suggested_project: "webapp")
#     → returns the list of keys it found (no values).
#   noxkey_admin(action: "import", entries: [...])
#     → raises the same review sheet for one-Touch-ID approval.

Step 4: Verify the import

Open the menu bar app and expand myorg/. Every key from the import is listed. Click any key to peek the first 8 characters and confirm it matches the original.

# Tree view (same data agents see via noxkey_show):
myorg/webapp/DATABASE_URL
myorg/webapp/OPENAI_API_KEY
myorg/webapp/STRIPE_SECRET_KEY
myorg/webapp/CLOUDFLARE_API_TOKEN
myorg/webapp/SESSION_SECRET
myorg/api-server/DATABASE_URL
myorg/api-server/API_SECRET
myorg/api-server/WEBHOOK_KEY

# Click STRIPE_SECRET_KEY → preview pane shows: sk_live_...
# (agents get the same via noxkey_show(account: "myorg/webapp/STRIPE_SECRET_KEY"))

Step 5: Update your workflow

Instead of relying on dotenv to auto-load a .env file, have your agent load secrets explicitly before running your app:

# Agent calls (one Touch ID covers the whole prefix for the session):
#   noxkey_get(account: "myorg/webapp", session: "4h")
#     → returns: source '/tmp/...sh'
# Agent runs that one line in Bash; every key under myorg/webapp
# is now in the shell environment via an encrypted handoff script.

# Subsequent reads under the same prefix — no prompt:
#   noxkey_get(account: "myorg/webapp/DATABASE_URL")
#   noxkey_get(account: "myorg/webapp/OPENAI_API_KEY")
#   noxkey_get(account: "myorg/webapp/STRIPE_SECRET_KEY")
$ npm run dev

Step 6: Delete the .env files

# The important part
$ rm .env

# Make sure .env is in your .gitignore (it should already be)
$ echo ".env" >> .gitignore

That's it. Your secrets are encrypted in the Keychain, protected by Touch ID, organized by project. No files on disk. No values in shell history.

When to use Keychain vs. other approaches

The Keychain isn't the right tool for everything. Here's how we think about it.

Use the macOS Keychain (via NoxKey) when:

Use 1Password / Bitwarden when:

Use HashiCorp Vault / Doppler / AWS Secrets Manager when:

Use your CI/CD platform's built-in secrets when:

Most developers have a gap between "secrets managed in CI" and "secrets on my laptop." That gap gets filled by .env files. It should be filled by the Keychain.

The macOS Keychain doesn't replace Vault or 1Password. It fills the gap between your CI/CD secrets and your laptop — the gap where .env files live today.

Keeping secrets safe from AI agents

If you use AI coding tools — Claude Code, Cursor, GitHub Copilot — your secrets workflow matters more than it used to. These tools read project files to build context. A .env file is a project file. If it's there, it gets read.

The Keychain approach kills that attack surface. Secrets aren't in any file the agent can access. NoxKey ships a bundled MCP server so agents can request secrets through a structured tool call (noxkey_get) instead of shelling out — and even when an agent does shell out, the menu bar app walks the process tree to recognise it.

Either way, NoxKey returns an encrypted handoff instead of the raw value: a source command pointing at a self-deleting temp script. The agent runs that one line, the secret lands in the shell as an env var, and the raw value never enters the agent's conversation context. We wrote more about how this works in our Keychain deep-dive.

Agent calls noxkey_get MCP server / process tree check Encrypted handoff returned Secret in env, never in context

Quick reference

NoxKey is driven from the menu bar app for human actions and the bundled MCP server for AI agents. The common operations:

Store a secret
Menu bar: copy value to clipboard, click Add Secret, pick org/project, name the key, hit Save. Agent: noxkey_set(account: "myorg/project/KEY_NAME", clipboard: true, field_type: "api_key") — raises an approval sheet in the menu bar app.
Retrieve a secret
Agent: noxkey_get(account: "myorg/project/KEY_NAME") returns a source '/tmp/...' command. Run it in Bash; the secret loads as $KEY_NAME. Touch ID required.
Browse secrets
Menu bar: open the tree view. Agent: noxkey_show() for the full tree (names only), or noxkey_show(account: "myorg/project/KEY_NAME") for the first 8 characters of one value. No Touch ID for either.
Import a .env file
Menu bar: drag the .env onto the app icon, confirm in the review sheet, one Touch ID writes the batch. Agent: noxkey_scan(path: ".", suggested_org: "myorg", suggested_project: "project"), then noxkey_admin(action: "import", entries: [...]) — same review sheet, same one-Touch-ID write.
Load every key under a prefix in one Touch ID
Agent: noxkey_get(account: "myorg/project", session: "4h"). Subsequent reads under the same prefix skip the prompt for the session window.
Mark a secret as strict (always Touch ID, even during sessions)
Menu bar: open the secret's detail view and toggle Always require Touch ID.
End a session early
Menu bar: open the session indicator and revoke the active unlock.
Key Takeaway
Your Mac has had a hardware-encrypted credential store for over 20 years. The native security CLI makes it painful — no Touch ID, values in shell history, no update-in-place. NoxKey wraps the same Keychain APIs with a developer-friendly menu bar app and a bundled MCP server for AI agents: clipboard-based Add Secret, Touch ID-gated noxkey_get with encrypted handoff, and org/project/KEY namespacing. Free, offline, no account required.

Download on the Mac App Store

Frequently asked questions

What's the macOS Keychain and how does it differ from file-based storage?
The macOS Keychain is an OS-level credential store. Unlike files on disk, secrets in the Keychain are encrypted using hardware-backed keys managed by the Secure Enclave. Accessing them requires authentication (Touch ID or password). A .env file has none of this — it's plaintext that any process can read without authentication.
Can I use the macOS Keychain from the command line?
Yes. macOS includes the security CLI for Keychain operations. Use security add-generic-password to store values and security find-generic-password -w to retrieve them. The catch: secret values end up in shell history, there's no Touch ID support, and you can't update items in place. NoxKey solves these by wrapping the Keychain APIs with a menu bar app for humans and an MCP server for AI agents — both Touch ID-gated, neither writes secret values to your shell history.
Does this work on Intel Macs or only Apple Silicon?
The Data Protection Keychain (with Secure Enclave backing) requires Apple Silicon or a T2 chip (MacBook Pro 2018+, MacBook Air 2018+, Mac Mini 2018, Mac Pro 2019, iMac 2020). Older Intel Macs without a T2 chip use the login keychain — still encrypted, but without hardware isolation. NoxKey falls back to password-based authentication on machines without Touch ID.
How do I migrate my .env files to the macOS Keychain?
Install NoxKey from the Mac App Store, then drag your .env file onto the menu bar icon. NoxKey shows you the keys it found and writes them to the Keychain in one batch — one Touch ID for the whole project. Delete the .env file afterward. Takes about a minute per project.
Do AI coding agents like Claude Code or Cursor access Keychain secrets?
AI agents can't read the Keychain directly — unlike .env files, there's no file on disk for them to open. NoxKey ships a bundled MCP server so agents request secrets through a structured noxkey_get tool call. The server returns an encrypted handoff (a source command pointing at a self-deleting temp script); the agent runs that one line, the secret lands in the shell as an env var, and the raw value never enters the agent's context window. More on this in why .env files are a liability with AI agents.