Last week, a North Korean state actor compromised the axios npm package — 100 million weekly downloads — and pushed a cross-platform RAT to every machine that ran npm install during a three-hour window.
The entire attack chain started with one thing: a long-lived npm token stored in plaintext on a developer's machine.
The attacker social-engineered the maintainer into installing malware. The malware harvested the token. The token was used to publish malicious versions. Even though the project had OIDC trusted publishing configured — the "right" way — the legacy plaintext token sitting alongside it provided a bypass.
One plaintext secret. 100 million affected installs.
This is not a one-off
The axios breach fits a pattern that's accelerating:
- 28.65 million new hardcoded secrets were pushed to public GitHub repos in 2025 — up 34% year-over-year (GitGuardian, 2026)
- 64% of secrets leaked in 2022 were still active in 2026 — nobody rotated them
- AI-assisted commits leak secrets at 2x the baseline rate (3.2% vs 1.5%)
- Stolen credentials appear in 31% of all breaches over the past decade
The Shai-Hulud worm that hit npm in September 2025 was even more direct — it ran TruffleHog on compromised machines specifically to harvest GITHUB_TOKEN, NPM_TOKEN, and AWS_ACCESS_KEY_ID from developers' environments.
The common thread: secrets that exist in plaintext — in .env files, in shell history, in CI variables, on disk — are attack surface waiting to be exploited.
The typical approach (and why it breaks)
Most projects handle secrets one of these ways:
1. .env files (gitignored)
# .env — "don't worry, it's in .gitignore"
CLOUDFLARE_API_TOKEN=v1.0-abc123def456
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Problems:
- The file sits on disk in plaintext. Any malware, any compromised dependency with filesystem access, any
postinstallscript can read it. - Developers copy it between machines via Slack, email, or shared drives.
-
.gitignoreis a single line of defense. Onegit add -Afrom a tired developer (or an AI assistant) and it's in the history forever. - New team members ask "can someone send me the .env?" in chat.
2. CI/CD secrets (GitHub Actions, etc.)
env:
API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Better for CI, but:
- Doesn't help local development.
- Secrets are still stored somewhere — now you have two sources of truth.
- Rotating means updating both the secret store and the CI config.
3. Vault/secrets manager
The enterprise answer. Often the right call for large teams. But for a solo developer or small team managing infrastructure? It's a lot of overhead for "I need my API token when I run terraform plan."
What I actually do: 1Password CLI + op run
I manage Cloudflare infrastructure for FormRecap (a form abandonment recovery SaaS) using Terraform. That means API tokens, account IDs, zone IDs, R2 storage credentials — a lot of secrets.
Here's the setup. It's three files, and zero secrets touch disk or git.
Step 1: The .env.op file (committed to git)
# .env.op — safe to commit. Contains zero secrets.
# These are 1Password references, not values.
# Cloudflare provider
CLOUDFLARE_API_TOKEN=op://Private/Cloudflare Terraform/api_token
TF_VAR_cloudflare_api_token=op://Private/Cloudflare Terraform/api_token
CLOUDFLARE_ACCOUNT_ID=op://Private/Cloudflare Terraform/account_id
TF_VAR_cloudflare_account_id=op://Private/Cloudflare Terraform/account_id
# Zone IDs
TF_VAR_zone_id_formrecap_com=op://Private/Cloudflare Terraform/zone_id_formrecap_com
TF_VAR_zone_id_endurebyte_com=op://Private/Cloudflare Terraform/zone_id_endurebyte_com
# ... more zones
# R2 state backend (S3-compatible)
AWS_ACCESS_KEY_ID=op://Private/Cloudflare Terraform/r2_access_key
AWS_SECRET_ACCESS_KEY=op://Private/Cloudflare Terraform/r2_secret_key
Every value is an op:// URI — a pointer to a field in a 1Password vault. The file itself contains nothing sensitive. It's committed to git, reviewed in PRs, and documents exactly which secrets the project needs.
Step 2: The Makefile (also committed)
OP := op run --env-file=.env.op --
init:
$(OP) terraform init
plan:
$(OP) terraform plan
apply:
$(OP) terraform apply
op run resolves every op:// reference at runtime, injects the real values as environment variables into the child process, and cleans them up when the process exits. The secrets exist only in memory, only for the duration of the command.
Step 3: One 1Password item
In 1Password, create a single item (I called mine "Cloudflare Terraform") with fields matching each op:// reference:
api_token: → your Cloudflare API token
account_id: → your Cloudflare account ID
zone_id_formrecap_com: → the zone ID
r2_access_key: → R2 S3-compatible access key
r2_secret_key: → R2 S3-compatible secret key
That's it. The entire workflow is:
make plan # 1Password prompts for biometric auth → secrets injected → plan runs
make apply # same flow
Why this is better than .env files
Nothing on disk. There's no file containing secrets for malware to read. If the axios RAT had been running on my machine, it would have found .env.op — a file full of op:// URIs that are useless without my 1Password vault and biometric authentication.
Biometric gate. Every op run invocation requires Touch ID (or your OS equivalent). Even if someone has shell access to your machine, they can't exfiltrate secrets without your fingerprint.
One source of truth. Secrets live in 1Password. Period. No syncing between .env, CI secrets, and a shared password doc. Rotate the token in 1Password and every make plan picks it up instantly.
Self-documenting. The .env.op file serves as a manifest of every secret the project needs, which vault it's in, and what it's called. New team member? Share the 1Password vault. They clone the repo, run make plan, and it just works.
No .gitignore anxiety. The env file is supposed to be committed. There's nothing to accidentally leak.
Setup (5 minutes)
# 1. Install 1Password CLI
brew install --cask 1password/tap/1password-cli
# 2. Enable CLI integration in 1Password desktop app
# Settings → Developer → "Integrate with 1Password CLI"
# 3. Create your secrets item in 1Password
# (manually, or via CLI)
op item create \
--category=login \
--title="Cloudflare Terraform" \
--vault="Private" \
'api_token=your-token-here' \
'account_id=your-account-id'
# 4. Create .env.op referencing those fields
# 5. Wrap your commands with: op run --env-file=.env.op -- <command>
It works with any tool, not just Terraform:
# Node.js
op run --env-file=.env.op -- node server.js
# Docker
op run --env-file=.env.op -- docker compose up
# Any CLI that reads environment variables
op run --env-file=.env.op -- wrangler deploy
What about CI/CD?
For GitHub Actions, 1Password has a dedicated action that uses a service account (no biometric needed in CI) and the same op:// references:
- uses: 1password/load-secrets-action@v2
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
CLOUDFLARE_API_TOKEN: op://Private/Cloudflare Terraform/api_token
One OP_SERVICE_ACCOUNT_TOKEN in GitHub secrets. Everything else resolves from 1Password. When you rotate a credential, you update it in one place.
The lesson from axios
The axios maintainer did a lot of things right. The project had OIDC trusted publishing. It had 2FA. It had a responsible disclosure process. But a single long-lived plaintext token — stored on disk, harvestable by malware — made all of that irrelevant.
The defense isn't "be more careful with .env files." The defense is not having secrets in files at all.
op:// references are inert data. They're like a map to a vault — useless without the vault key (your biometric). The actual secret never touches the filesystem, never appears in shell history, never gets committed to git, and never persists after the process exits.
For a solo developer managing production infrastructure — or a maintainer of a package with 100 million weekly installs — that difference is the whole ballgame.
I'm building FormRecap, a form abandonment recovery tool that runs entirely on Cloudflare's developer platform. The Terraform setup described in this article manages all of FormRecap's infrastructure — DNS, D1 databases, KV namespaces, R2 buckets, Pages projects, and AI Gateway — across 8 domains with zero plaintext secrets.
If your forms lose 70% of visitors before they hit submit, give it a try — free tier, one script tag, no backend changes.
Top comments (0)