macOS Credential Management: Replace Plaintext Secrets with security CLI
This article demonstrates how to use macOS's built-in security command-line tool to store API tokens and passwords in the system keychain, retrieve them on demand, inject them into processes via environment variables, and understand the security boundaries of keychain storage, authorization prompts, and runtime exposure.
API tokens placed in scripts risk being committed with code; storing them in .env files creates another plaintext file to protect. For development tools running only on a personal Mac, the author prefers centralizing credentials in the system keychain and reading them at tool startup.
macOS includes /usr/bin/security for this purpose, requiring no extra credential manager. However, moving storage to Keychain does not automatically extend the same protection to credentials after retrieval. Using this workflow well requires considering storage, authorization, and runtime exposure together.
The article uses single-line API tokens and passwords as examples. Command examples use Bash/Zsh syntax; interactive prompts follow the local security help output.
1. Find the Correct Help Entry
Many CLIs support --help, but security uses a different interface. Running security --help may return illegal option -- -, which usually just means the help flag is wrong.
security -h
security help
security help add-generic-password
security help find-generic-password
security help delete-generic-password securityalso manages certificates, identities, and trust settings. For saving API tokens, the generic-password command group is the primary focus.
Three distinct protections exist: Keychain handles credential storage and access control; FileVault protects the disk volume; Secure Enclave provides specific hardware security capabilities. They are related but cannot be conflated — ordinary password entries should not be described as "keys always stored in Secure Enclave." security operates mainly on file-based keychains, which differ from data-protection keychains in implementation.
2. Locate Credentials with Service and Account Names
The author recommends a consistent, searchable naming convention:
Service: myapp/dev/api_token — expresses project, environment, and credential purpose.
Account: dev-admin — expresses the account or identity using the credential.
Slashes are only a naming convention; they create no directories or automatic permission boundaries. To avoid ambiguity from default keychain or search list changes, the login keychain path is specified explicitly:
KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"This is the common path; replace with a custom keychain path if needed. View current configuration with:
security default-keychain -d user
security list-keychains -d userSave and Update: Let the Command Prompt for Password
security add-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
-U \
"$KEYCHAIN" \
-wThe valueless -w is deliberately placed last. The local help confirms this interactive prompt style, avoiding writing the secret directly into command-line arguments. -a specifies Account, -s specifies Service. -U allows updating an existing matching entry; it still creates on first save. Daily use should always include the full locating conditions and the same keychain path.
If no keychain is specified, addition uses the default keychain; queries without a path use the default search list. These two defaults are not the same scope.
Retrieve: Standard Output Contains Plaintext
security find-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
-w "$KEYCHAIN"The query command's -w prints only the password to standard output, not metadata. It is not an abbreviation for an English phrase nor a masking switch.
This command suits learning the interface, but real credentials should not be printed directly to the terminal. Terminal scrollback, screen recordings, and logs can retain copies. In actual scripts, capture output directly and verify the read succeeded.
Delete: Clean Up Local Copies
security delete-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
"$KEYCHAIN"Deleting the Keychain entry does not revoke the remote token. After a credential leak, revoke or rotate on the server side first, then clean up the local copy.
3. Inject on Demand, Keep Secrets Brief
Putting the read command in .zshrc is convenient but runs on every shell startup. Exported variables are inherited by subsequently launched child processes, widening the credential's visibility.
The author prefers defining a function in the shell configuration that reads the credential only when actually used. The diagram below shows the blue arrow reaching only a single tool invocation; the orange boundary emphasizes that once the secret leaves the keychain, it enters the process runtime scope.
Credentials enter the tool process on demand; keychain protection does not automatically extend to the process environment.
Using an existing myapp command as an example, a wrapper function can be defined:
run_myapp() (
# Keep tracing off while handling secrets.
set +x
keychain="$HOME/Library/Keychains/login.keychain-db"
if token=$(/usr/bin/security find-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
-w "$keychain"); then
if [ -z "$token" ]; then
printf '%s
' 'Credential is empty.' >&2
return 1
fi
else
printf '%s
' 'Cannot read the credential.' >&2
return 1
fi
export MYAPP_API_TOKEN="$token"
unset token
exec myapp "$@"
) myappis an example tool; replace with the actual program and confirm it accepts an environment variable interface like MYAPP_API_TOKEN.
The function body uses parentheses to run in a subshell. The parent shell does not retain the newly exported credential long-term. exec replaces the current subshell with the tool; when the tool exits, that invocation's environment ends. Child processes spawned by the tool may still inherit the variable.
The example assigns first, then checks the return status. Do not write export TOKEN=$(security ...) and then check $? directly: the captured status may be from export, masking a read failure. local TOKEN=$(...) has a similar issue.
Also do not prepend 2>/dev/null. Missing credentials, access denial, or a locked keychain all need investigation. Silencing error output lets the script continue with an empty variable.
Environment variables remain a plaintext interface. They may be exposed via debugging, diagnostics, process inspection, or tool logs; visibility depends on system permissions and tool behavior. On-demand injection shortens exposure time but cannot isolate malicious code that already has equivalent user privileges.
Disabling set -x in the example prevents subsequent shell tracing from printing variable expansions; outer scripts, CI logs, and the target tool's own debug settings must be checked separately.
4. PostgreSQL: -w Means Something Completely Different
The following usage cannot pass a database password:
# Incorrect: -w does not take a password.
psql -h prod-db.internal -U postgres -d maindb -w "$DB_PASS" psql -wmeans "do not prompt for password." The following string is not treated as a password argument to -w.
If short-lived environment variable use is acceptable, read in a subshell then invoke:
db_connect() (
set +x
keychain="$HOME/Library/Keychains/login.keychain-db"
if password=$(/usr/bin/security find-generic-password \
-a "postgres" -s "infra/prod/postgres" \
-w "$keychain"); then
[ -n "$password" ] || return 1
else
printf '%s
' 'Cannot read the database credential.' >&2
return 1
fi
export PGPASSWORD="$password"
unset password
exec psql -h prod-db.internal -U postgres -d maindb -w "$@"
)The database address is a placeholder. PostgreSQL officially discourages using PGPASSWORD to store passwords because some systems allow other users to view process environments. This demonstrates a deliberate, temporary passing method with explicit trade-offs.
If the environment cannot accept this risk, consider permission-restricted password files or server-supported authentication mechanisms. Password files reintroduce plaintext-on-disk issues, requiring permission, lifecycle, and backup management. The choice should serve the actual threat model; "zero plaintext files" need not be an overriding goal above all other conditions.
5. Hidden Input, Authorization Dialogs: What Each Solves
read -s Only Solves Part of the Echo Problem
Using read -s to capture input, then running security ... -w "$SECRET_VAL", usually avoids writing the secret literal into interactive history, but the expanded variable value still enters the external process arguments.
Therefore, the article prefers security 's built-in interactive prompt. Do not treat echo "$TOKEN" | security ... as a standard input interface; the -w parameter semantics must be understood from the help documentation. unset only removes the variable; it does not promise cryptographic secure erasure of process memory. Shell command substitution also strips trailing newlines from output, so this text-based approach suits common single-line credentials, not arbitrary binary secrets.
"Always Allow" Must Be Understood with the Calling Program
Whether an access prompt appears depends on the entry's access control, the requesting program, and the keychain state. It cannot be simply attributed to using iTerm2 or Ghostty.
Particularly note: security add-generic-password help states that the creating application is trusted by default and can access its data without prompting. When both creation and reading use /usr/bin/security, do not expect every read to require a password.
"Always Allow" affects the entry's application access authorization; it does not mean only the current shell script is authorized. Other scripts may also call the same trusted program.
To remove the creating program's default trust, specify explicitly at creation:
security add-generic-password \
-a "dev-admin" \
-s "myapp/dev/approval_token" \
-T "" \
"$KEYCHAIN" \
-wThis is a separate entry example. -T "" does not equal forcing biometric authentication every time; subsequent prompts and authorization still require actual system verification. Existing entry ACLs should be verified in Keychain Access; do not assume a single update resets all historical authorizations.
Do not use -A to eliminate prompts. This option allows any application to access the entry without warning, widening the authorization scope.
6. Use It Where It Fits
For personal Mac development tools, Keychain is well-suited as a local credential store: built-in, direct script interface, and reduces secret copies in project directories.
Cross-platform sync, team sharing, approval workflows, and centralized rotation require additional design. Tools like gopass can combine encrypted storage with Git workflows; more complex team environments may use dedicated secret management services. Keychain has backup and migration mechanisms, but security should not be treated as a ready-made cross-platform team credential system.
Long-running unattended CI also needs separate evaluation. Login keychain unlocking and authorization prompts may not adapt to tasks without a graphical session. Do not hardcode the keychain password into scripts just to unlock the keychain.
The author's daily principle is simple: credentials centralized in Keychain; avoid secrets appearing in arguments during writes; stop on read failure; inject only into necessary processes. Combined with FileVault, least-privilege tokens, and regular rotation, this approach reduces common plaintext copies, but running programs must still bear responsibility for protecting credentials.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ops Development & AI Practice
DevSecOps engineer sharing experiences and insights on AI, Web3, and Claude code development. Aims to help solve technical challenges, improve development efficiency, and grow through community interaction. Feel free to comment and discuss.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
