TL;DR

Use two independent auth channels, each backed by 1Password, each with a different lifetime model. Don't try to unify them.
Channel 1 · click to jump to setup
git push / pull / clone
SSH key, signed by 1Password agent
Touch ID per signature · key never on disk
Channel 2 · click to jump to setup
gh pr create · gh api · gh run
Fine-grained PAT, JIT fetched
1-year expiry · op plugin · vault-bound

01 The threat

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."

Interactive · See what a malicious package can grab

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.

$ # click an action above to see what leaks

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.

02 The threat surface, by storage option

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.

How to read this correctly For each row, the cell in the column matching its storage primitive is the load-bearing cell — that's the attack vector that actually applies to that storage. The other cells being green is uninformative: of course "env vars" is green for the keychain row; the token isn't in env vars. Compare rows by their load-bearing cells: env column for the GH_TOKEN row, disk column for hosts.yml, keychain column for the keychain rows, env column (brief) for 1Password JIT.
Highlight:
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
exposed at rest brief window only protected
Hover a cell for the reasoning.
The bottom row is the ceiling. Hardware-attested keys (YubiKey for SSH; Secure Enclave for some signing operations) are strictly stronger than software vaults. For most solo devs, 1Password + Touch ID approximates this against the threats actually faced — the marginal hardening matters mainly against persistent malware with memory-introspection capability, not against npm post-install hooks.
Why hardened Keychain isn't equivalent to 1Password JIT in practice. The CONDITIONAL rating is real — at maximum hardening (custom non-login keychain, short auto-lock, pruned ACLs, per-access password) Keychain approaches 1Password JIT's security ceiling. But "maximum hardening" is a configuration you have to actively maintain: every "Always Allow" click is a permanent silent-access grant, every long auto-lock timeout widens the window, every dropped audit lets stale ACLs accumulate. 1Password JIT achieves the same ceiling by default, with per-fetch biometric, and without operational discipline as a load-bearing assumption. Choose 1Password not because it's cryptographically superior, but because it's harder to misconfigure into weakness.

03 The setup

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.

Channel 1: SSH via 1Password agent ~10 min · one time

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.

1. Enable 1Password's SSH agent

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.

2. Generate the key in 1Password

New Item → SSH Key → Generate → Ed25519. Copy the public key to GitHub → Settings → SSH and GPG keys.

3. Point 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.

4. Force git to use SSH for all GitHub URLs

git config --global url."[email protected]:".insteadOf "https://github.com/"

5. Verify

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.

Channel 2: Fine-grained PAT via op plugin ~15 min · one time

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.

1. Generate the PAT in GitHub

Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new. Configure:

  • Resource owner: the org you'll use gh against (don't pick "all repos")
  • Expiration: 1 year. Annual rotation is defensible for solo dev; see Section 04.
  • Repository access: only the repos you'll actually run gh against
  • Permissions: Contents read, Pull requests RW, Issues RW, Metadata read, Actions read. Don't grant workflows/admin/packages.

2. Store the PAT in 1Password

New item (Login or API Credential). Title: GitHub PAT — <your-org> (fine-grained). Add a field called token with the PAT value.

3. Initialize the op plugin (run in your real terminal, not Claude Code)

op plugin init gh

Pick the 1Password item from step 2. Choose "global default" unless you want per-directory scoping.

4. Tell gh to prefer SSH for any git URLs it constructs

gh config set git_protocol ssh --host github.com

5. Remove stale state that would shadow the op plugin

# 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.

6. Verify channel separation

gh auth status                 # PAT path — no Touch ID
git push --dry-run origin master  # SSH path — Touch ID fires
Defense in depth: gitleaks, expiry monitor, scope audit ~15 min · one time

Pre-commit secret scanning with gitleaks

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

Expiry monitoring

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.

Audit your actual gh usage and prune scopes

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.

04 Rotation reality check

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:

365 days
now3mo6mo9mo12mo
Rotations per year
1
Annual rotation overhead
~2 minutes
365 days is the right answer for your threat model. The token is in 1Password JIT — leak window is one command, not one year. Monthly rotation theater costs ~24 min/year of friction for negligible additional security.

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.

05 Why not X?

The popular alternatives, each rejected for a specific reason. Documented so you don't re-litigate.

weak gh auth login --web with macOS Keychain default
The default login.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.
misleading gh auth login -p ssh instead of a PAT
The -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.
weak Classic PAT instead of fine-grained
Classic PAT scopes are coarse: 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.
disaster GH_TOKEN exported in ~/.zshrc
Token lives in every shell's environment, inherited by every child process. npm 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.
complex GitHub App with local JWT signing
Cryptographically elegant: store a private signing key in 1Password, mint 1-hour installation tokens on demand. Compromise window drops from 1 year to 1 hour. But: GitHub Apps were designed for server-to-server integrations; gh CLI has no first-class App support; installation tokens don't work for /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.
overkill YubiKey-backed SSH (ed25519-sk)
For the supply-chain threat specifically, 1Password SSH agent with per-use Touch ID is essentially equivalent. Both block "malicious post-install reads ~/.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.
overkill Sandboxed package installs (devcontainers, sandbox-exec)
Running every 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.

06 Setup checklist

Progress saves to your browser's localStorage — close the tab and come back to where you were.

0 of 0 complete

Channel 1: SSH

Channel 2: PAT

Defense in depth

Progress saved to localStorage on this device.