Library · Launch: payments, secrets, rules, logs

Security in Claude Code: .env, secrets and data protection

Confident user55 minUpdated: October 2026
85 of 105 in the library

Module: Professional practice | Time: ~25 min theory + 30 min practice


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 repository
  • settings.json permissions: allow/deny lists for Claude's tools; deny rules are what actually hide files from Claude (there is no .claudeignore file 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

🎨 Picture this: Claude Code is a very smart contractor you've given the keys to the whole office. They can walk into any room and read any document. Usually that's a plus. But if you accidentally left the keys to the safe on a desk in the common area, the problem isn't the contractor anymore.

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

🎨 Picture this: .env is the safe in the back room of a store. The display window (your code) is for everyone. The safe (your secrets) is only for the cashier. You don't keep cash in the window.

The basic structure of a .env file:

bash
# .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=info

Next to .env, you create a .env.example: a template without values that does go into Git:

bash
# .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=development

This is your documentation for coworkers (and for yourself six months from now): which variables the project needs to run.

File structure in the project:

Code
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

🎨 Picture this: .gitignore is the list of things you don't put in the shared closet. The team can see the closet. Your passport and the key to the safe stay in your pocket.

Create it before your first git init, or at least before your first git add:

bash
# .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/
*.swp

Check before every commit:

bash
git status
# .env should not show up in the list — if it does, stop

If .env has already made it into Git (it happens):

bash
git rm --cached .env
git commit -m "Remove .env from tracking"
# Be sure to rotate ALL the keys in that file — they're compromised

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

json
{
  "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 Read blocks 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.

🎨 Picture this: If .gitignore is the list of what not to send to coworkers, deny rules are the list of what not to show a temporary consultant. A consultant doesn't need to see your client contracts to set up your bookkeeping. And remember: a "Do not enter" sign on a door with no lock (.claudeignore) protects nothing.


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

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 .env even if you ask it to
  • deny: ["Bash(rm -rf *)"]: protection against accidentally deleting everything
  • deny: ["Bash(git push --force *)"]: force pushes only by hand, never through Claude
  • The allow list: 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)

🎨 Picture this: it's like the access list at a secure site. The guard only lets in people on the list, even if someone says "the director said it's fine." The list beats words.

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

🎨 Picture this: A pre-commit hook is like the metal detector at the exit of a secure facility. You can't leave without going through it. Forgot a key in your pocket? The detector goes off before you reach the street.

It's created in .git/hooks/pre-commit:

bash
#!/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 0

Installation:

bash
chmod +x .git/hooks/pre-commit

Or with pre-commit (a more powerful option):

bash
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: gitleaks
bash
pre-commit install
# Now it runs automatically on every git commit

Checking that the hook works:

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

Production secrets: Cloudflare, Vercel, Railway

In production, there is no .env file. Secrets are stored in the platform's encrypted stores.

Cloudflare Workers Secrets:

bash
# 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_KEY

In a Cloudflare Worker's code, secrets are available through the env object:

javascript
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:

bash
# 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 Variables

Railway:

bash
# 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 → Variables

The 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

🎨 Picture this: 1Password is the bank safe deposit box where the original is kept. .env is the working copy in your pocket. Cloudflare secrets are the copy at the office. There's only one original, and it's in the safe.

Vault structure in 1Password:

Code
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 Token

1Password CLI: filling in .env automatically:

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

No 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:

Type this into the chat
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

🎨 Picture this: A doctor presenting a clinical case at a conference never names the patient. "A 45-year-old man" instead of "John Smith." The same principle applies to client data you send to an LLM.

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:

python
# 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:

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

bash
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 .env

Step 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

bash
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)"
    ]
  }
}
EOF

Step 4: Pre-commit hook

bash
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-commit

Test that the hook works:

bash
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.txt

Step 5: Security audit with Claude

Open Claude Code in the project folder and type:

Type this into the chat
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

bash
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 empty

Result: 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 inject to fill in .env automatically
  • 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 .claudeignore file in Claude Code. .gitignore tells Git what not to track, and deny rules in settings.json tell Claude what not to read.

Clients' PII is pseudonymized before it's sent to an LLM. Client_42 instead 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