If you publish private npm packages to GitHub Packages, every pnpm install (or npm install) that pulls them needs a GitHub personal access token (PAT) set in the current environment.
The easiest way is to paste it into into ~/.zshrc as an export line, but this approach has two problems:
- it's in plaintext - every time the file is opened, the program (or AI agent) that opens it, sees the secret token, which could lead to accidental exposure.
- the token is shown in shell history - the export TOKEN=ghp_... gets added to every shell startup and is accessible in ~/.zsh_history.
This article talks about how to keep your tokens both out of the dotfile and the history without needing to change anything in the projects that consume the packages - on macOS using Keychain and on a Linux server using a locked-down config file.
Note that this is just a tidindess win, not a sandbox - any process running as your user inside a login shell still sees the variable, so it does not protect you if your machine is compromised, but it does protect you from accidentally leaking your tokens from eg. commiting tokens to the repo, accidently syncing the token with another computer or an AI agent seeing it when looking through the shell history.
We're using npm as the practical example, but a similar setup applies to practically any tool that reads secrets from an environment variable or lets its config file reference one.
How it works
The pattern has three layers, each pointing at the next:
- The tool's config file references an environment variable instead of holding the value. Most CLIs support this natively (
${VAR}interpolation in a config file, or a documented*_TOKENvariable), so the committed and user-level config never contain a secret. - The shell fills the variable on startup from wherever the secret actually lives. On macOS that is Keychain, read with
security find-generic-password; on a Linux desktop it would besecret-toolorpass. - The secret is written to that store through a hidden prompt, never as a command-line argument, so it never lands in shell history.
With npm and GitHub Packages the layers look like this:
- the project .npmrc (committed) only says which scope lives on which registry
- the user-level ~/.npmrc supplies the token for that registry via a variable
- and the variable is filled from Keychain:
project/.npmrc -> @yxmaxy:registry=https://npm.pkg.github.com # committed, no secret
~/.npmrc -> //npm.pkg.github.com/:_authToken=${YXMAXY_NODE_TOKEN}
~/.zshrc -> export YXMAXY_NODE_TOKEN="$(security find-generic-password ...)"
macOS Keychain -> the only place the value is written down
On a Linux machine (where there is no Keychain) the chain is slightly shorter:
- the token is written directly into ~/.npmrc with chmod 600. The point in both cases is the same - the value is entered through a hidden prompt, never typed on a command line, and never committed.
For any other tool swap ~/.npmrc for eg. ~/.pypirc, ~/.aws/credentials - the approach is the same.
Setup
Now that we have an overview of the approach, the following steps help with setting up the full pipeline:
Step 1 - create a token with the minimum scope
GitHub > Settings > Developer settings > Personal access tokens > Tokens (classic) > Generate new token.
It's good practice to give the token the narrowest scope possible - in this case it's the read:packages permission if installing over SSH (see the Keeping git on SSH section below), while if you install over HTTPS you also need to add the repo permission.
Step 2 - macOS machine: store the token in Keychain
- Add the token to Keychain.
securityprompts for the password interactively (you need to enter it twice), so it never lands in shell history. If the entry already exists and you need to overwrite it, add-U.
security add-generic-password -a "$USER" -s YXMAXY_NODE_TOKEN -w
- Make the shell read it back on startup by replacing the plaintext export in
~/.zshrcwith:
export YXMAXY_NODE_TOKEN="$(security find-generic-password -a "$USER" -s YXMAXY_NODE_TOKEN -w 2>/dev/null)"
- Make sure
~/.npmrcreferences the variable rather than a literal value:
//npm.pkg.github.com/:_authToken=${YXMAXY_NODE_TOKEN}
- The project
.npmrcstays as it was - just the scope-to-registry mapping:
@yxmaxy:registry=https://npm.pkg.github.com
- Open a new shell and verify without printing the secret:
exec zsh
[ -n "$YXMAXY_NODE_TOKEN" ] && echo "loaded (len=${#YXMAXY_NODE_TOKEN})" || echo "EMPTY"
The first time a new shell reads the Keychain item, macOS may pop a dialog: "security wants to use your confidential information stored in ... in your keychain". Click Always Allow, otherwise it prompts on every shell launch.
Step 3 - Linux server: write the token to a locked-down ~/.npmrc
- The token goes straight into the user-level
~/.npmrc.read -rspreads it from a hidden prompt, so again, nothing ends up in history:
read -rsp 'GitHub PAT: ' TOK && printf '//npm.pkg.github.com/:_authToken=%s\n' "$TOK" >> ~/.npmrc && unset TOK && echo && echo 'written'
chmod 600 ~/.npmrc
The project .npmrc in the checked-out repo already maps the scope to the registry, so this one _authToken line is all the user-level file needs.
- Verify with a real install rather than a full deploy - it should finish without a
401:
cd /path/to/project/frontend
CI=true corepack pnpm install --frozen-lockfile 2>&1 | tail -15
Keeping git on SSH
To make the narrow read:packages permission scope possible for downloading packages (so the repo scope isn't necessary), we can rewrite any HTTPS references to use SSH authentication instead (assuming, you added your SSH key to be allowed on GitHub):
git config --global url."git@github.com:".insteadOf "https://github.com/"
This applies to every https://github.com/... URL git sees, including git dependencies in package.json that reference GitHub by HTTPS. Note that it also rewrites public repos, so the machine needs an SSH key registered with GitHub even for those - set it on the server only if a deploy key or user key is already in place.