Ten years ago, in [2016-05-06-2]
, I introduced a function called my-lisp-load. You write things like access tokens into files under ~/.emacs.d/spec/, and init.el loads them by file name.
The goal back then was to keep credentials from leaking when publishing init.el on GitHub. Since spec/ isn’t tracked by git, it still serves that purpose today.
However, “not tracked by git” and “not on disk” are two different things. The tokens in spec/ are stored in plaintext, so they’re defenseless against malware that grabs the whole ~/.emacs.d/ directory.
That felt a bit shaky as a 2026-era measure, so I migrated them to the macOS Keychain.
Note that everything below assumes macOS. On Linux, substitute something like secret-tool.
Reading from Keychain with auth-source.el
Here’s the my-secret-load function I wrote:
(defun my-secret-load (name)
"Load secret of NAME from macOS Keychain via auth-source
Register it beforehand as an internet password, since auth-source in
Emacs 31.1 cannot find generic passwords. Putting -w last prompts for
the value, so it never appears in argv. Add -U to update an existing
item.
$ security add-internet-password -s NAME -a emacs -w"
(require 'auth-source)
(let ((auth-sources '(macos-keychain-internet))
(auth-source-do-cache nil))
(or (auth-source-pick-first-password :host name :user "emacs")
(error "Cannot read %s from Keychain" name))))
my-secret-load is a thin wrapper around auth-source-pick-first-password from auth-source.el, which ships with Emacs. It limits the search to internet passwords in the macOS Keychain and raises an error if nothing is found.
By default, auth-source.el caches search results for two hours. However, it also caches “not found” results, so if you call it once before registering the item in Keychain, you won’t be able to retrieve it for a while even after registering. That’s why I disabled the cache.
Wherever credentials were being loaded, I replaced my-lisp-load with my-secret-load.
;; before
(setq foo-access-token (my-lisp-load "foo-access-token"))
;; after
(setq foo-access-token (my-secret-load "foo-access-token"))
Register the item in Keychain beforehand like this:
$ security add-internet-password -s foo-access-token -a emacs -w
-a is the account name, which I’ve fixed to emacs. Since my-secret-load passes it internally as :user "emacs", callers only need to pass the name.
Putting -w last lets you enter the value interactively. It’s better not to use -w "value", because the value would end up in plaintext in your shell history, such as ~/.zsh_history.
I chose Keychain as the storage because my password managers differ between machines: 1Password on my personal machine and Keeper on my work machine. With Keychain, both machines can use the same mechanism.
auth-source.el supports storage backends other than Keychain, but I didn’t go with them for the following reasons:
~/.authinfo.gpg- Prompts for a passphrase when decrypting
- Some access tokens are loaded at Emacs startup, so if the gpg-agent cache has expired, I’d have to enter the passphrase every time I start Emacs
- pass CLI (auth-source-pass.el)
- It’s GPG under the hood, so the passphrase hassle is the same as
~/.authinfo.gpg - It also requires installing pass CLI separately
- It’s GPG under the hood, so the passphrase hassle is the same as
- 1Password (auth-source-1password.el, etc.)
- Requires a third-party package and the 1Password CLI
- As mentioned above, my work machine uses Keeper, so it wouldn’t be consistent
Keychain is unlocked when you log in to macOS, so I don’t need to type anything when Emacs starts.
Also, what actually accesses Keychain isn’t Emacs.app itself, but the security CLI that auth-source.el invokes. Keychain records access permissions per application, so updating Emacs.app doesn’t trigger a new permission prompt.
By the way, I’m still using my-lisp-load. Values like user-mail-address aren’t credentials, but they differ between my personal and work machines, and I’d rather not publish them, so they stay in spec/.
Generic Passwords Can’t Be Retrieved in Emacs 31.1
Keychain has two kinds of passwords: generic passwords and internet passwords. auth-source.el supports both. For storing app settings, a generic password is the more natural fit, but I couldn’t retrieve one in Emacs 31.1.
(let ((auth-sources '(macos-keychain-generic))
(auth-source-do-cache nil))
(auth-source-pick-first-password :port "foo-access-token" :user "emacs"))
;; => nil
With macos-keychain-generic, the value you want passed to the security CLI’s -s (service name) goes in :port, not :host. It’s a confusing mapping, but the code above follows it. Even so, it returns nil.
Because :type Gets Stripped Along the Way
Looking through lisp/auth-source.el
, I found the cause.
auth-source-search passes :type to the backend’s search function. The receiving auth-source-macos-keychain-search narrows down the spec keys with auth-source-search-spec
before handing them to auth-source-macos-keychain-search-items, which does the actual work. The keys excluded at this point are auth-source-ignored-keys
.
(defconst auth-source-ignored-keys
'(:create :delete :max :backend :label :require :type)
"List of meta keys to be ignored in data stores.")
Since :type is in the list, it gets removed from the spec here.
Meanwhile, the search-items side
looks at the type keyword argument to decide whether it’s a generic password.
(cl-defun auth-source-macos-keychain-search-items (coll _type _max
host port user
&key label type
&allow-other-keys)
(let* ((keychain-generic (eq type 'macos-keychain-generic))
type is always nil, so find-internet-password runs instead of find-generic-password. No wonder the generic password can’t be found.
Sure enough, calling search-items directly with an explicit :type retrieved it.
(auth-source-macos-keychain-search-items
"default" nil 1 nil "foo-access-token" "emacs"
:type 'macos-keychain-generic)
;; => ((:type macos-keychain-generic :secret ... ))
Probably a Regression in 31.1
In Emacs 30.2, auth-source.el could retrieve the same generic password. Up through 30.2, the keychain backend had its own list of ignored keys, and :type wasn’t in it.
;; Filter out ignored keys from the spec
(ignored-keys '(:create :delete :max :backend :label :host :port))
The loop that filters out ignored keys had been copy-pasted in three places, and the keychain backend’s copy had a FIXME comment. The contents of the list differed per backend, and the other two included :type. It seems that when a142cc26
(2025-10-05) consolidated them, :type slipped into the keychain backend too.
Neither the commit message nor the 31.1 NEWS
mentions any change to how generic passwords are handled. The docstring of auth-source-macos-keychain-search still includes a usage example for macos-keychain-generic, so it’s hard to believe this was an intentional change. I think it’s most likely a regression.
With macos-keychain-internet, find-internet-password runs even when type is nil, so it ends up retrieving the password correctly. Only generic is broken. I suspect the fact that it only affected one of the two is part of why it went unnoticed.
As far as I could tell from searching debbugs by subject, nobody has reported it. I’m not going to report it for now, and plan to follow along once it’s fixed on the Emacs side.
So I decided to register the items as internet passwords.
What Keychain Can’t Protect Against
Moving to Keychain only protects against attackers who just exfiltrate files from ~/.emacs.d/.
It can’t stop anyone who can run commands with the same user privileges. They can simply run security find-internet-password -s foo-access-token -w and get the token.
Keychain does have per-application access control, but the only application allowed is the security CLI.

So there’s no way to tell whether it was called from Emacs or from a shell.
At that point, the only effective measures are something that requires a human in the loop every time, like Touch ID, or narrowing the token’s own permissions and keeping its lifetime short. I think the realistic approach is to assume it will be stolen and minimize the damage.
Conclusion
I migrated the credentials I had stored in plaintext files under ~/.emacs.d/spec/ to the macOS Keychain.
2016 (my-lisp-load) |
2026 (my-secret-load) |
|
|---|---|---|
| Protects against | Leaks when publishing on GitHub | Malware that exfiltrates files |
| What I did | Moved values out of init.el into files not tracked by git | Stopped storing them in plaintext on disk and moved them to Keychain |
| Remaining issues | Still plaintext on disk | Tied to macOS. Can’t stop anyone who can run the security CLI with the same user privileges |
Ten years later, I unintentionally tied up a loose end. Along the way, I also ran into what looks like a regression in Emacs 31.1.
Even after moving to Keychain, anyone who can run commands with the same user privileges can still read the tokens. I think defense in depth matters: combine hiding tokens in Keychain with narrowing their permissions and keeping their lifetimes short.
I hope this helps someone.