Keep your Stripe key out of the agentThree patterns. One rule.
A live Stripe key in your shell environment is one agent session away from appearing in a state file or a context window. You can prevent both without changing how your code charges customers.
-
01
State
sk_live_ in shell env
Agent inherits it on every session launch
exported · ~/.zshrc -
02
Method
Broker injects by scope
Test key to agent, live key only to proxy
per-process, not ambient -
03
Result
Agent context stays clean
Live key never enters LLM context or state file
proxy holds the secret
TL;DR· the answer, in twenty seconds
What: Every time you run an agent session with a Stripe live key exported in your shell, that key is available to the agent, to any tool it calls, and to any state file it writes. Claude Code's settings.local.json incident (Knostic, Feb 2026) showed that state files containing env vars end up in public packages.
Fix: Use sk_test_ keys for local agent work, Stripe restricted API keys where you need scoped live access, and a 30-line proxy server that holds the live key and accepts calls from localhost. The agent calls the proxy, never Stripe directly.
Lesson: The shape of the fix is always the same: stop keeping long-lived secrets in ambient environment variables and hand them to processes at execution time in the smallest scope that works.
You have STRIPE_SECRET_KEY=sk_live_... exported in your shell. You open a Claude Code or Cursor session to work on billing code. The agent reads your environment. So does every tool it spawns, every subprocess it runs, every state file it writes.
Knostic disclosed in February 2026 that Claude Code's settings.local.json was capturing environment variables and shipping them in npm packages at a rate of roughly 1 in 13. GitGuardian's 2026 State of Secrets Sprawl report put AI-assisted repos leaking secrets at about 2x the rate of the baseline. The mechanism is always the same: a long-lived secret in your environment, an agent that inherits it, a file somewhere that persists what the agent saw.
Stripe live keys have a specific blast radius. A leaked sk_live_ key lets an attacker issue refunds, read customer data, create charges, and pull your full transaction history. Restricted API keys narrow that window, but most people export the full admin key out of habit.
Changing one habit and adding two pieces of infrastructure gets you there.
The short version
- Use
sk_test_...keys for any local work where an agent is involved. The agent codes and tests against test mode. Live keys never enter the session. - Where you need real live data, use Stripe restricted API keys scoped to the minimum read/write your task actually needs.
- Where the agent must trigger real charges, wrap Stripe in a small localhost server that holds the live key. The agent calls
localhost:8787/charge, notapi.stripe.comdirectly. - If you still need to run a live key in a session, use scoped runtime injection: inject it into a single child process for one command, then take it away.
- Never export
sk_live_in~/.zshrc. Never paste live keys into chat. Never commit.env.production.
Three patterns that work
Pattern 1: Use Stripe test keys for all local agent work
Set up a second Stripe account or use your existing test mode. Export the test key in your development shell:
export STRIPE_SECRET_KEY=sk_test_51...
Give your agent a .env.test file that it reads automatically:
# .env.test - committed to the repo, no secrets
STRIPE_SECRET_KEY=sk_test_51...yourTestKey...
STRIPE_PUBLISHABLE_KEY=pk_test_51...yourTestPublishableKey...
The agent codes against this. Webhooks, charges, customer creation, subscription logic: all of it works in test mode with Stripe's full API surface. The agent can read customer IDs, test webhook payloads, simulate failures.
When you're done and ready to deploy, your production environment (CI, server, Vercel, whatever you use) gets the live key through a separate, agent-free path. The agent never touches that path.
The only limitation: Stripe test mode does not produce real card network responses, real bank declines, or real fraud signals. If you're building something that depends on live card behavior, you need Pattern 3.
Pattern 2: Restricted API keys for scoped live access
Stripe restricted API keys (the rk_live_ prefix) let you create a key with read-only on customers, write-only on charges, or any combination of the 40-odd resources Stripe exposes. Create them in the Stripe dashboard under Developers > API keys > Create restricted key.
If your agent is analyzing a billing anomaly, give it a key with read-only access to charges and customers. If it's generating a financial report, give it read-only on everything and nothing else.
# For a session that only needs to read customer data
export STRIPE_SECRET_KEY=rk_live_...readOnlyCustomers...
A leaked rk_live_ with read-only on customers is not great, but an attacker cannot issue charges or refunds with it. Damage radius drops significantly.
Stripe's restricted keys do not expire on their own. Revoke them when the task is done. The dashboard lets you revoke individually. Treat them more like SSH keys than passwords: one per task, revoked on completion.
Pattern 3: Wrap calls in a small server you own
When your code actually needs to send charges to Stripe from a session an agent is touching, put a thin server between the agent and Stripe. The server holds the live key. The agent calls the server.
A Node/Express proxy that handles a charge:
// proxy/stripe-proxy.js
const express = require('express');
const Stripe = require('stripe');
const app = express();
app.use(express.json());
// Live key never leaves this process
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY_LIVE, {
apiVersion: '2024-06-20',
});
// Only accept calls from localhost
app.use((req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') {
return res.status(403).json({ error: 'forbidden' });
}
next();
});
app.post('/charge', async (req, res) => {
const { amount, currency, customer, description } = req.body;
if (!amount || !currency || !customer) {
return res.status(400).json({ error: 'missing required fields' });
}
try {
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
customer,
description,
});
res.json({ id: paymentIntent.id, status: paymentIntent.status });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(8787, '127.0.0.1', () => {
console.log('Stripe proxy running on localhost:8787');
});
Start the proxy in a terminal before your agent session. Give the proxy the live key; give your agent session only the proxy URL:
# Terminal 1: proxy holds the live key (not in the agent's env)
STRIPE_SECRET_KEY_LIVE=sk_live_... node proxy/stripe-proxy.js
# Terminal 2: agent session has no Stripe key at all
export STRIPE_PROXY_URL=http://localhost:8787
claude
The agent makes charge calls without seeing sk_live_:
# Example prompt you'd give the agent
curl -s -X POST http://localhost:8787/charge \
-H 'Content-Type: application/json' \
-d '{"amount": 2000, "currency": "usd", "customer": "cus_abc123"}'
The agent's context window contains only the proxy URL and the response JSON. No live key appears anywhere in what the agent reads or writes.
Add proxy/ to your .gitignore if you're storing the startup script locally. The live key only lives in Terminal 1's environment, which is not the agent's environment.
A broker like hasp automates this split. Instead of running a handwritten proxy server, you register the live key in the vault and give the agent session a reference. The agent's environment contains no sk_live_ value; the broker injects it into the specific subprocess that needs it. The proxy pattern above is the right manual version of that model.
When you must run a live key in the agent session
If you have a specific command that genuinely requires live key access inside the agent's environment, scope it as tightly as possible. Inject the key for one child process and let it die with that process:
# Inject for one command, not for the session
STRIPE_SECRET_KEY=sk_live_... node scripts/migrate-customers.js
Prefixing without export means the key exists only while node scripts/migrate-customers.js is running. The agent does not inherit it. The shell drops it when the command exits.
Add a redactor configuration so that if sk_live_ or rk_live_ patterns appear in tool output, they are caught before the agent processes them. A redaction config that catches both:
# .hasp-redact.yaml (or your redactor's config format)
patterns:
- name: stripe_live_secret
regex: 'sk_live_[A-Za-z0-9]{24,}'
replace: '[STRIPE_LIVE_KEY]'
- name: stripe_restricted_live
regex: 'rk_live_[A-Za-z0-9]{24,}'
replace: '[STRIPE_RESTRICTED_KEY]'
If Stripe returns error JSON that includes a key echo (some API errors reflect request headers), the redactor catches it before the agent sees the value in the tool output stream.
Treat this as a fallback for a one-shot migration script, not your default. Your default is Pattern 1 or Pattern 3.
What not to do
Four concrete things that cause real incidents:
export STRIPE_SECRET_KEY=sk_live_... in ~/.zshrc. Every agent session you open inherits this key. The state files and tool calls that follow inherit it too. Remove this line today and replace it with project-scoped injection.
Paste a live key into chat to "just test something real quick." The key is now in the LLM's context window, potentially in the provider's logs, and in your local conversation history file. Use a test key, which costs you nothing and gives you identical behavior.
Commit .env.production or any file containing sk_live_. .gitignore does not protect files already tracked. Run git log --all -- '.env.production' to check if you ever staged it.
Drop Stripe webhook payloads or invoice JSON into the agent chat when those responses contain customer email addresses, last-four digits, and billing addresses. No key is exposed, but PII goes into a context window that may be logged. Use redacted test fixtures instead.
What "use environment variables" actually means
"Use environment variables for secrets, not hardcoded strings" is correct and incomplete. It was written for the world where a secret manager injects one value into one server process at deploy time. That remains the right model.
An environment variable is not a safe boundary when an agent reads your process environment, writes state files in your repo, and calls tools that log their inputs. Exporting a value into your interactive shell turns it into ambient state for every process you launch, including agents.
Stripe's documentation tells you to store the secret key in an environment variable. The docs predate agent tooling, so they do not say that a long-lived interactive shell is not the same as a scoped server process. The principle the docs are pointing at: a secret should exist in exactly one process's memory, for exactly as long as that process needs it. Ambient shell exports break both constraints. The proxy pattern and per-command injection restore them.
Checklist to paste into your security PR
## Stripe key / agent hygiene
- [ ] sk_live_ removed from ~/.zshrc, ~/.bashrc, ~/.profile
- [ ] .env.production in .gitignore; git log --all -- .env.production clean
- [ ] Agent sessions use sk_test_ or rk_live_ (restricted) by default
- [ ] Stripe restricted keys created with minimum required permissions
- [ ] Restricted keys revoked when task is complete (not left open-ended)
- [ ] Stripe proxy running for any agent session that needs live charges
- [ ] STRIPE_PROXY_URL (not STRIPE_SECRET_KEY) in agent session env
- [ ] proxy/ startup script does not commit live key to repo
- [ ] Redactor config covers sk_live_ and rk_live_ patterns
- [ ] No Stripe webhook JSON with customer PII pasted into agent chat
- [ ] CI / server environments get live key through secret manager, not shell export
- [ ] git log -p -S sk_live_ run against all repos that ever had agent sessions
Fix any "no" before moving to the next item. Partial hardening leaves the exposure intact.
What this means for your stack
The Stripe key problem is a specific case of a general pattern: a long-lived secret in ambient environment state, an agent that reads that environment, and multiple downstream paths (state files, logs, context windows) that can carry the value somewhere you did not intend.
The structural fix is a runtime broker that holds secrets in an encrypted local vault, injects values into specific child processes when you call hasp run, and keeps an HMAC-chained audit log of every grant. The agent's environment contains a reference, not the live value. State files the agent writes hold references too. hasp is one working implementation. curl -fsSL https://gethasp.com/install.sh | sh, hasp setup, connect your project, and the next agent session gets a scoped reference instead of an ambient key. Source-available (FCL-1.0), local-first, macOS and Linux, no account.
The proxy pattern in Pattern 3 achieves the same isolation manually. Automate it or wire it by hand, the result is the same: the agent gets the minimum context its job requires, and secrets are almost never part of that minimum.
Sources· cited above, in one place
Stop handing the agent your real keys.
hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.
- Local, encrypted vault — no account, no cloud, no telemetry by default.
- Brokered run — agent gets a reference, the child process gets the value.
- Pre-commit + pre-push hooks catch managed values before they ship.
- Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.