A two-channel setup that defends against supply-chain compromise of locally-installed packages, without the friction of enterprise-grade overkill.
The primary attack this setup defends against is a compromised npm or RubyGems package running its install hooks on your machine. The npm and gem ecosystems have repeatedly seen post-install scripts that read environment variables, scan home directories, or query the system keychain for tokens and SSH keys, then exfiltrate them to an attacker-controlled endpoint.
The exposed surface depends entirely on where your credentials live. The goal of this guide is to make sure the answer is "in 1Password, encrypted at rest, fetched only into the process that needs them for the duration of one command."
Click each action below to simulate what a hostile postinstall hook running as your user can read from a default setup. Notice how many credentials sit at rest, accessible.
The matrix in the next section shows which storage option exposes what to each of these vectors. The setup recommended here moves credentials out of every red cell.
This matrix is the central decision-making tool. Each row is a place a GitHub credential — PAT or SSH key — could live; each column is an attack vector a compromised package can run. Red cells are bad. Hover any cell for the reasoning. Toggle a column off to focus on one threat at a time.
GH_TOKEN row, disk column for hosts.yml, keychain column for the keychain rows, env column (brief) for 1Password JIT.
| Where the GitHub token lives | env vars | disk scan | keychain | process mem |
|---|---|---|---|---|
| GH_TOKEN exported in .zshrc | EXPOSED | safe* | safe | EXPOSED |
| gh auth login → ~/.config/gh/hosts.yml | safe | EXPOSED | safe | safe |
| macOS Keychain (default: login.keychain) | safe | safe | WEAK | safe |
| macOS Keychain (hardened: custom keychain, short auto-lock, ACL pruned) | safe | safe | CONDITIONAL | safe |
| 1Password vault + op plugin (JIT) | brief | safe | safe | brief |
| Hardware-attested (YubiKey, Secure Enclave) | safe | safe | safe | safe |
Three independent components. They compose; you can adopt them in any order. The interactive checklist at the bottom of this page tracks progress across all three.
This replaces ~/.ssh/id_* files with a key that lives in 1Password and is served via 1Password's local SSH agent. Every git push triggers a Touch ID prompt; the key bytes never touch disk.
In the 1Password app: Settings → Developer → "Use the SSH agent". Also enable "Display key names when authorizing connections". Critically: ensure biometric authorization is required per use, not on a cached-while-unlocked basis. This is the single setting that makes 1Password ≈ YubiKey against this threat.
New Item → SSH Key → Generate → Ed25519. Copy the public key to GitHub → Settings → SSH and GPG keys.
ssh at the agent (via ssh_config, not env)Add to ~/.ssh/config:
Host github.com IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
1Password's docs prefer IdentityAgent over exporting SSH_AUTH_SOCK: the socket path stays out of every child process's environment (one less thing for a malicious post-install hook to enumerate), survives env-scrubbing wrappers like sudo, and scopes per-host so future agents (gpg-agent, YubiKey) don't collide. Fall back to SSH_AUTH_SOCK only for SSH clients that ignore ssh_config.
git config --global url."[email protected]:".insteadOf "https://github.com/"
ssh -T [email protected] # Expected: Touch ID fires, then "Hi! You've successfully authenticated..." git push --dry-run origin master # Expected: Touch ID fires.
If no Touch ID prompt: you're using HTTPS+credential-helper somewhere. Check git remote -v and git config --global --get-all credential.helper.
The gh CLI needs a bearer token to talk to api.github.com. This puts that token in 1Password and uses the op plugin to fetch it just-in-time into gh's process env for a single command.
Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new. Configure:
New item (Login or API Credential). Title: GitHub PAT — <your-org> (fine-grained). Add a field called token with the PAT value.
op plugin init gh
Pick the 1Password item from step 2. Choose "global default" unless you want per-directory scoping.
gh config set git_protocol ssh --host github.com
# Check for leftover token storage grep -rE 'GH_TOKEN|GITHUB_TOKEN' ~/.zshrc ~/.zprofile ~/.zshenv ~/.profile 2>/dev/null cat ~/.config/gh/hosts.yml 2>/dev/null git config --global --get-all credential.helper
Remove any GH_TOKEN exports, delete hosts.yml if it has an oauth_token line, and unset gh as a credential helper if present.
gh auth status # PAT path — no Touch ID git push --dry-run origin master # SSH path — Touch ID fires
Catches the "accidentally committed a .env" leak class that no amount of PAT hardening prevents.
brew install gitleaks # Then in each repo's .git/hooks/pre-commit: gitleaks protect --staged --redact -v
GitHub emails you ~7 days before a fine-grained PAT expires, and any gh call against an expired token returns HTTP 401: Bad credentials. No local monitoring script needed — the signal already arrives through two independent channels.
fc -l 0 | awk '/^[0-9]+ +gh / {print $2,$3,$4}' | sort | uniq -c | sort -rn | head -20
Whatever's in your top 5 is what the PAT needs to support. Drop the rest from the token's permission scopes.
The instinct is to rotate often — monthly, even. For a solo dev with the token in 1Password, the marginal security gain from short rotation is small, and the friction cost compounds. One year is defensible. Try the slider:
When GitHub's expiry email arrives (~T-7), open the PAT in GitHub UI → Regenerate (preserves the name/scopes), paste new value into the 1Password item, done. ~2 minutes.
The popular alternatives, each rejected for a specific reason. Documented so you don't re-litigate.
gh auth login --web with macOS Keychain defaultlogin.keychain auto-unlocks at boot and stays unlocked. The matrix's WEAK rating is real, not theoretical — the three failure modes are: (1) the "Always Allow" trap (most users have permanently granted silent access to common CLI tools without realizing); (2) prompt failures in non-TTY contexts (Docker builds, piped processes, background hooks) where the call returns immediately; (3) no per-access biometric by default — macOS supports it but it can only be set at item creation via the Security framework C API, which gh doesn't use. Hardened Keychain (custom keychain, short auto-lock, pruned ACLs) approaches 1Password JIT's ceiling — but it treats sustained operational discipline as load-bearing, and 1Password achieves the same ceiling by default.
gh auth login -p ssh instead of a PAT-p / --git-protocol flag is misleadingly named. It controls only the git protocol gh prefers when it initiates git operations (gh repo clone, gh repo fork). It does not change how gh authenticates to the GitHub API. The REST/GraphQL API does not accept SSH key auth — there is no SSH-based way to call /repos/.../pulls. So gh auth login -p ssh -w still runs the OAuth device flow to mint a Bearer token; it just additionally configures git to use SSH URLs. The resulting token lands in macOS Keychain by default, which moves your API credential from 1Password's vault to weaker storage. The -p ssh setting only affects the git side, which you already configured via git config url.insteadOf and gh config set git_protocol ssh. The two-channel model exists because GitHub treats these surfaces independently.
repo grants access to every repo you can see. If leaked, blast radius is your entire GitHub presence. Fine-grained PATs scope to specific repos with specific permissions — much smaller blast radius for the same operational pattern.
GH_TOKEN exported in ~/.zshrcnpm install post-install hooks read it with one line. This is exactly the attack vector the whole setup is designed to defeat. The same applies — worse — to OP_SERVICE_ACCOUNT_TOKEN, which grants vault access rather than a single service.
/user/* endpoints (you'd need a fallback PAT anyway); the JWT-signing glue code is fragile and you'd be a population of one debugging when it breaks. Prefer orthodox security patterns to clever ones when operating solo.
ed25519-sk)~/.ssh/id_*" identically. YubiKey wins against more capable threats — persistent malware with memory-introspection, or forensic recovery of an unencrypted laptop. Worth revisiting if you start storing production secrets with real PII access on the same machine.
npm install / bundle install inside a container that can't see host env or filesystem is the most thorough defense against this exact threat. But the workflow friction is substantial — rare even at security-focused shops. If you want this, the right move is wholesale shift to Codespaces or a remote dev environment, not piecemeal sandboxing on the host.
Progress saves to your browser's localStorage — close the tab and come back to where you were.