The gist
Claude Code sees everything in your project folder, and one careless git push can accidentally show it to the whole internet. Setting up a security stack is like putting a lock on the door before you move in, not after the first break-in.
Key concepts
.env: a file of secrets; it lives locally and never goes into Git.gitignore: the list of things Git doesn't pack into the repositorysettings.jsonpermissions: allow/deny lists for Claude's tools; deny rules are what actually hide files from Claude (there is no.claudeignorefile in Claude Code)- Pre-commit hook: an automatic check before each commit that stops a leak
- Cloudflare / Vercel secrets: secret storage for production
- 1Password CLI: the master vault everything else comes from
- PII pseudonymization: clients' personal data never goes to an LLM raw
Theory
Why this matters: Claude Code sees everything
While it works, Claude Code reads files in the current directory. When you ask it to "figure out this project," it looks at everything: source code, configs, sometimes .env. If a git commit and git push happen after that, everything Claude didn't exclude goes into the repository.
What a mistake really costs:
- An AWS key in a public repo → bots find it within minutes → a bill for thousands of dollars overnight
- An Anthropic API key → someone burns through your whole limit over the weekend → your project goes down
- A Stripe production key → direct access to your clients' money
- A database URL → a full dump of your database, client data included
A rule to remember once and for all: a secret that has ever been in Git history is considered public. Even if you ran git rm in the next commit, it's in the history forever. The only fix: rotate the key.
The .env structure: right the first time
The basic structure of a .env file:
# .env — NEVER commit to Git
# === AI / LLM ===
ANTHROPIC_API_KEY=sk-ant-api03-...
OPENAI_API_KEY=sk-proj-...
PERPLEXITY_API_KEY=pplx-...
# === Payments ===
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# === Database ===
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
# === Slack / Bots ===
SLACK_BOT_TOKEN=xoxb-...
# === Environment settings ===
NODE_ENV=development
LOG_LEVEL=infoNext to .env, you create a .env.example: a template without values that does go into Git:
# .env.example — COMMIT THIS FILE (no values, only keys)
ANTHROPIC_API_KEY=sk-ant-your-key-here
OPENAI_API_KEY=sk-proj-your-key-here
STRIPE_SECRET_KEY=sk_live_your-key-here
DATABASE_URL=postgresql://user:password@host:5432/dbname
SLACK_BOT_TOKEN=your-token-here
NODE_ENV=developmentThis is your documentation for coworkers (and for yourself six months from now): which variables the project needs to run.
File structure in the project:
project/
├── .env ← real keys (NOT in Git)
├── .env.example ← template without values (in Git)
├── .env.test ← mock keys for tests (not in Git)
├── .gitignore ← protection
├── .claude/
│ └── settings.json ← rules: what Claude doesn't read or run
└── src/.gitignore: the first line of defense
Create it before your first git init, or at least before your first git add:
# .gitignore
# Secrets — NEVER in Git
.env
.env.local
.env.*.local
.env.production
.env.staging
# Keys and certificates
*.pem
*.key
*.p12
*.pfx
id_rsa
id_ed25519
# Dependencies
node_modules/
__pycache__/
*.pyc
.venv/
# System files
.DS_Store
Thumbs.db
# IDE
.cursor/
.idea/
*.swpCheck before every commit:
git status
# .env should not show up in the list — if it does, stopIf .env has already made it into Git (it happens):
git rm --cached .env
git commit -m "Remove .env from tracking"
# Be sure to rotate ALL the keys in that file — they're compromisedHow to hide files from Claude: permissions.deny (not .claudeignore)
A common mistake: many articles and AI answers suggest creating a .claudeignore file. There is no such file in Claude Code. It does nothing and gives you a false sense of security. There's one approach that works: deny rules in .claude/settings.json (see the next section). Git and Claude are different tools, each with its own boundaries: .gitignore tells Git what not to track, and deny tells Claude what not to read.
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Read(**/*.pem)",
"Read(**/*.key)"
]
}
}What else helps:
- Don't keep live secrets in the project folder: during development you only need a dev key, while production keys live in your platform's secret store (see the production section below).
- A deny on
Readblocks the built-in read tool. To also close the workarounds through the command line (cat .env), turn on the sandbox (/sandbox, available on supported platforms) and add deny rules for the relevant commands. - Folders with client data (
data/clients/,exports/,backups/) should be blocked with the same rules, or kept outside the project entirely.
settings.json permissions: allow/deny lists
Hack #30 from the 32 Claude Code hacks lesson: settings.json lets you lock in what Claude can and can't do, no matter what you type in the chat.
File: .claude/settings.json
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git status)",
"Bash(git log *)",
"Bash(npm install)",
"Bash(npm run *)",
"Bash(node *)",
"Bash(python *)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force *)",
"Bash(git push -f *)",
"Bash(DROP *)",
"Bash(DELETE FROM *)",
"Read(./.env)",
"Read(./.env.*)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(./secrets/**)"
]
}
}What this gets you:
deny: ["Read(./.env)"]: Claude doesn't read.enveven if you ask it todeny: ["Bash(rm -rf *)"]: protection against accidentally deleting everythingdeny: ["Bash(git push --force *)"]: force pushes only by hand, never through Claude- The
allowlist: an explicit list of what's permitted; everything else needs confirmation (in auto mode, which has been the default starting mode since version 2.1.283, a separate classifier model also checks actions, but deny rules remain the main barrier)
Important: settings.json is committed to the repository (no secrets, only rules). That way the settings travel with the project.
Pre-commit hook: an automatic check
It's created in .git/hooks/pre-commit:
#!/bin/bash
# Pre-commit security check
# Install with: chmod +x .git/hooks/pre-commit
echo "Checking for secrets before commit..."
# Patterns that must not get into a commit
PATTERNS=(
"sk-ant-"
"sk-proj-"
"sk_live_"
"sk_test_"
"AKIA[0-9A-Z]{16}"
"whsec_"
"xoxb-"
"xoxp-"
"ghp_"
"glpat-"
"password\s*=\s*['\"][^'\"]{8,}"
"secret\s*=\s*['\"][^'\"]{8,}"
"DATABASE_URL\s*=\s*postgresql"
)
FOUND=0
for PATTERN in "${PATTERNS[@]}"; do
if git diff --cached | grep -iE "$PATTERN" > /dev/null 2>&1; then
echo ""
echo "STOP: a secret was found in the commit!"
echo "Pattern: $PATTERN"
echo ""
git diff --cached | grep -iE --color "$PATTERN"
FOUND=1
fi
done
if [ $FOUND -eq 1 ]; then
echo ""
echo "Commit blocked. Remove the secrets from the staged files."
echo "Use: git reset HEAD <file> to unstage a file"
exit 1
fi
echo "No secrets found. Commit allowed."
exit 0Installation:
chmod +x .git/hooks/pre-commitOr with pre-commit (a more powerful option):
pip install pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.x.x # check the gitleaks repository for the current tag
hooks:
- id: gitleakspre-commit install
# Now it runs automatically on every git commitChecking that the hook works:
# Temporarily add a line with a "secret" to a file
echo "ANTHROPIC_API_KEY=sk-ant-test123" >> test.txt
git add test.txt
git commit -m "test"
# The block should kick in
git reset HEAD test.txt
rm test.txtProduction secrets: Cloudflare, Vercel, Railway
In production, there is no .env file. Secrets are stored in the platform's encrypted stores.
Cloudflare Workers Secrets:
# Set a secret (wrangler will ask for the value interactively)
wrangler secret put ANTHROPIC_API_KEY
> Enter a secret value: [you type the key — it isn't shown on screen]
# List the secrets you've set (names only, values aren't available)
wrangler secret list
# Delete one
wrangler secret delete OLD_KEYIn a Cloudflare Worker's code, secrets are available through the env object:
export default {
async fetch(request, env) {
// env.ANTHROPIC_API_KEY — comes from wrangler secret
const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
}
};Vercel Environment Variables:
# Through the CLI
vercel env add ANTHROPIC_API_KEY production
vercel env add ANTHROPIC_API_KEY preview
vercel env add ANTHROPIC_API_KEY development
# Or through the dashboard: vercel.com → Project → Settings → Environment VariablesRailway:
# Through the CLI (check Railway's documentation for the command syntax; it has changed)
railway variables set ANTHROPIC_API_KEY=sk-ant-...
# Or through the dashboard: railway.app → Project → VariablesThe principle: different environments, different keys. A dev key with a small spending limit. A production key with a working limit. A bug during development shouldn't spend your production budget.
1Password CLI: the master vault
Vault structure in 1Password:
1Password → AI Projects (a separate vault)
├── Newsletter Automation
│ ├── Anthropic API Key (prod)
│ ├── Anthropic API Key (dev) ← separate, with a limit
│ └── Stripe Keys
├── Slack Bot Project
│ └── Bot Token
└── Shared Infrastructure
├── Cloudflare API Token
└── GitHub Token1Password CLI: filling in .env automatically:
# Installation
brew install 1password-cli
op signin
# In .env.example, point to 1Password references
ANTHROPIC_API_KEY=op://AI-Projects/Newsletter/anthropic-prod-key
STRIPE_SECRET_KEY=op://AI-Projects/Newsletter/stripe-secret
# Fills in .env from the vault automatically
op inject -i .env.example -o .envNo more copying keys by hand: just op inject and you're done.
A security review by Claude
The fastest way to find problems in an existing project is to ask Claude to audit it:
Do a security review of this project: 1. Are there any API keys or passwords hardcoded in the code (not in .env)? 2. Are all sensitive files in .gitignore? 3. Is there a .env.example with no real values? 4. Is there any client PII in the logs or in hardcoded strings? 5. Are the permissions in .claude/settings.json set up correctly? Show me a list of the problems you found, with the file and line for each.
The result is a concrete list: src/api.js:23 — STRIPE_KEY is hardcoded. You fix them one by one. For changes on a branch, there's also the built-in /security-review command: it checks the differences between your branch and the main one for common vulnerabilities. It's a helper, not a replacement for a human review.
PII and client data: pseudonymization
PII (Personally Identifiable Information), meaning names, phone numbers, emails, Social Security numbers, tax IDs and addresses, should never go to Claude raw.
Before: what not to do:
# Sending real data to Claude — a violation
prompt = f"""
Analyze this client:
Name: John Smith
Phone: +1 555 123-4567
Email: [email protected]
Budget: $80,000
"""After: the right way:
# Pseudonymize before sending to the LLM
def pseudonymize(client):
return {
"id": f"Client_{client['id']}",
"budget_usd": client['budget'],
"region": client['city'], # region only, not the exact address
"property_type": client['type']
}
client_data = pseudonymize(raw_client)
prompt = f"""
Analyze this client:
ID: {client_data['id']}
Budget: ${client_data['budget_usd']:,}
Region: {client_data['region']}
Property type: {client_data['property_type']}
"""Rules for PII:
- Names →
Client_42,User_789 - Phone numbers → don't send them unless the task needs them
- Emails → don't send them unless needed
- Budget → can be sent as ranges (
$50K-100K) if the exact number isn't needed - Addresses → city/region only, not the exact address
This isn't paranoia. It's GDPR in Europe, state privacy laws in the US, and common sense everywhere. More on regulation: AI Regulation & Compliance 2026.
Practice
Assignment: set up a basic security stack
Step 1: Basic protection
mkdir secure-project && cd secure-project
git init
# Create .gitignore
cat > .gitignore << 'EOF'
.env
.env.*
*.pem
*.key
node_modules/
__pycache__/
.DS_Store
EOF
# Create .env with test data
cat > .env << 'EOF'
ANTHROPIC_API_KEY=sk-ant-test-placeholder
STRIPE_SECRET_KEY=sk_live_test-placeholder
DATABASE_URL=postgresql://localhost:5432/testdb
EOF
# Create .env.example (goes into Git)
cat > .env.example << 'EOF'
ANTHROPIC_API_KEY=sk-ant-your-key-here
STRIPE_SECRET_KEY=sk_live_your-key-here
DATABASE_URL=postgresql://user:password@host:5432/dbname
EOF
# Check: .env should not be in git status
git status
# Output: only .gitignore and .env.example — NOT .envStep 2: Move anything unnecessary out of the project folder
Check that the project folder only contains a dev key. Keep live keys in your platform's secret store and your password manager. You don't need to create a .claudeignore file: it doesn't work, and we'll block files with rules in the next step.
Step 3: settings.json: block files and dangerous commands
mkdir -p .claude
cat > .claude/settings.json << 'EOF'
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git status)",
"Bash(git log *)",
"Bash(npm install)",
"Bash(npm run *)",
"Bash(node *)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force *)",
"Read(./.env)",
"Read(./.env.*)",
"Read(**/*.pem)",
"Read(**/*.key)"
]
}
}
EOFStep 4: Pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
echo "Security check..."
PATTERNS=("sk-ant-" "sk-proj-" "sk_live_" "whsec_" "AKIA" "ghp_")
FOUND=0
for P in "${PATTERNS[@]}"; do
if git diff --cached | grep -E "$P" > /dev/null 2>&1; then
echo "STOP: secret found (pattern: $P)"
FOUND=1
fi
done
[ $FOUND -eq 1 ] && exit 1
echo "OK — no secrets found"
exit 0
EOF
chmod +x .git/hooks/pre-commitTest that the hook works:
echo "ANTHROPIC_API_KEY=sk-ant-realkey123" >> test-leak.txt
git add test-leak.txt
git commit -m "test"
# It should block the commit
git reset HEAD test-leak.txt && rm test-leak.txtStep 5: Security audit with Claude
Open Claude Code in the project folder and type:
Do a security audit of this project: 1. Are there any hardcoded secrets in .js/.py files? 2. Is .gitignore set up correctly? 3. Are sensitive files blocked by deny rules in .claude/settings.json? 4. Check .claude/settings.json: are the restrictions sufficient? Give me a list of specific problems with files and lines.
Step 6: Your first secure commit
git add .gitignore .env.example .claude/settings.json
git commit -m "Security stack: gitignore, settings.json deny rules, pre-commit hook"
# Check that .env didn't get in
git show HEAD --name-only | grep .env
# The output should be emptyResult: three layers of protection (.gitignore + pre-commit hook + settings.json deny) significantly reduce the risk of a secret accidentally leaking into Git. They're not a full guarantee: you still need to store keys properly and rotate them at the slightest suspicion.
Tools and resources
- gitleaks: a scanner for leaked secrets in Git repositories,
brew install gitleaks - git-secrets: a pre-commit hook from AWS,
brew install git-secrets - pre-commit: a framework for pre-commit hooks,
pip install pre-commit - 1Password CLI:
op injectto fill in.envautomatically - Cloudflare Workers Secrets:
wrangler secret put - Vercel Environment Variables: through the dashboard or
vercel env add - dotenv (Node.js):
npm install dotenv - python-dotenv (Python):
pip install python-dotenv - GitHub Secret Scanning: on by default; it'll notify you if a key ends up in a public repo
- Claude Code docs: Permissions: official documentation on access rules
Key takeaways
A secret that ends up in Git history is considered public forever, even if the repository is private. The only fix: rotate the key.
Three layers of protection:
.gitignore(doesn't track), pre-commit hook (blocks the commit),settings.json deny(Claude doesn't read). Together they greatly reduce the risk of a leak.
There is no
.claudeignorefile in Claude Code..gitignoretells Git what not to track, and deny rules insettings.jsontell Claude what not to read.
Clients' PII is pseudonymized before it's sent to an LLM.
Client_42instead of "John Smith."
Dev and prod always get different keys. A bug during development shouldn't cost you your production budget.
Next lesson
→ AI Ethics & Safety: hallucinations, attacks, bias, and why not to trust AI blindly
The mark stays in this browser only and is never sent anywhere. My progress