A Claude Code mod can redact sensitive data at the two places it moves: on the way into the session and on the way out. It can mask an API key you paste into a prompt, hand Claude a masked copy of a .env file a command just printed, and refuse a Slack or ticket MCP call that would carry a card number, with a reason so Claude does the job another way. If the secret lives in a file like .env, a Read deny rule that stops Claude reading it at all is the first step;6 the mod covers what slips through. Below: exactly where a mod can step in, a small tested mod that does all three, and what it will still miss.

Where sensitive data gets into a session, and out of it

Most leaks in a coding session are not dramatic. You paste an error message and the connection string with the password comes along. Claude runs cat .env or printenv to debug a config problem. A query result holds customer emails and phone numbers. A log line carries a bearer token. Each of these lands in the conversation, and from there it goes to the model with every following request.

The other direction matters as much. With MCP servers connected, Claude can post to Slack, write a ticket, or call an external API. If a key or a customer's IBAN is in the context, it can end up in that message without anyone meaning it to.

So the rule we want is simple: every session cleans the obvious things before they go in, and checks before anything goes out. That covers API keys and tokens, passwords, personal data such as emails and phone numbers, and payment details such as card numbers and IBANs.

You could write that rule in CLAUDE.md. But that is guidance, and the model can still miss it. A mod is code around the session: every prompt and tool call it hooks passes through it, so Claude cannot skip it. We cover that difference in more depth in rules Claude cannot ignore. If mods are new to you, start with Claude Code mods, explained simply.

Where a mod can step in

Before writing any code, we checked the docs and the type declarations for Claude Code v2.1.287 for each point where data moves. A mod hook gets the event, and next(e) hands it on to the rest of the chain and finally to Claude Code itself.2 What the hook does with next decides whether it observes, rewrites, or answers. Here is what is possible at each point:

Where data movesMod eventWhat the hook can do
Your prompt, before Claude reads itprompt.submitRewrite the text with next({ ...e, text }), add context only Claude reads, or stop the prompt with { drop: reason }
A tool call, before it runstool.call (before next)Change the arguments with next({ ...e, ... }), or refuse with { deny: reason }. Claude reads the reason as the tool result.
A tool result, before Claude reads ittool.call (after next)Let the tool run with await next(e), then return { result } with your own copy. Claude reads that copy.
An MCP tool calltool.call with tool: /^mcp__/Same as any tool: refuse, rewrite, or mask the result. Calls a subagent makes pass through too.
The start of a turnturn.startObserve only. Not a place to clean anything.

The sources for each row: the events guide shows prompt rewriting, the deny answer, and that tool.call fires for MCP tools and for calls a subagent makes.2 The reference lists { result } as an answer to tool.call and turn.start as observe only.3 The type declarations say that when a hook returns its own { result }, Claude Code checks it against the tool's output schema, formats it for the model, and records it in the transcript as the tool's result (from the Claude Code 2.1.287 type declarations).

To be fair to settings hooks: they already do a lot of this. A PreToolUse hook can deny a call with a reason Claude sees, and a PostToolUse hook can replace the output Claude reads with updatedToolOutput.5 What a UserPromptSubmit hook cannot do is rewrite the prompt itself. It can only block it or add context. A mod can rewrite the prompt. It also keeps the three checks in one file that shares its patterns, runs as functions inside Claude Code's own process instead of as a shell command,1 and comes with a test runner.8

The mod: secret-scrub

A mod is a plugin folder with a manifest, a hooks.json that points at the code, and the code.1 Ours has one more file for the patterns, and a test file:

secret-scrub/
secret-scrub/
├── .claude-plugin/
│   └── plugin.json
├── hooks/
│   ├── hooks.json
│   ├── patterns.ts
│   └── register.ts
└── tests/
    └── secret-scrub.test.ts
hooks/hooks.json
{ "modules": ["./register.ts"] }

The patterns. Each rule is a regular expression, with an extra check where a shape alone is too loose. Card numbers must pass the Luhn checksum that valid card numbers pass, so an order id of 16 digits stays. IBANs must pass the mod 97 check. Phone numbers only match in international form, such as +49 ..., to keep version numbers and ids out. For KEY=value lines like the ones in a .env file, only the value is masked, so Claude still knows the key exists. The same goes for the password inside a connection string.

hooks/patterns.ts
// What counts as sensitive. Each rule has a name, a pattern, and an optional
// check that cuts false positives (a Luhn check for cards, mod 97 for IBANs).
type Rule = { name: string; re: RegExp; check?: (match: string) => boolean }

const digits = (s: string) => s.replace(/[^0-9]/g, '')

// Card numbers: the Luhn checksum that valid card numbers pass
function luhn(s: string): boolean {
  const d = digits(s)
  if (d.length < 13 || d.length > 19) return false
  let sum = 0
  for (let i = 0; i < d.length; i++) {
    let n = Number(d[d.length - 1 - i])
    if (i % 2 === 1) { n *= 2; if (n > 9) n -= 9 }
    sum += n
  }
  return sum % 10 === 0
}

// IBANs: move the first four characters to the end, letters to numbers, mod 97 must be 1
function ibanOk(s: string): boolean {
  const v = s.replace(/\s+/g, '').toUpperCase()
  if (v.length < 15 || v.length > 34) return false
  const moved = v.slice(4) + v.slice(0, 4)
  let rest = 0
  for (const ch of moved) {
    const n = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch
    for (const c of n) rest = (rest * 10 + Number(c)) % 97
  }
  return rest === 1
}

export const RULES: Rule[] = [
  { name: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
  { name: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
  { name: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
  { name: 'stripe-key', re: /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}/g },
  { name: 'aws-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
  { name: 'github-token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})/g },
  { name: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
  { name: 'google-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
  // KEY=value lines, as in a .env file: only the value is masked
  { name: 'secret-value', re: /\b[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*\s*[=:]\s*["']?[^\s"']{6,}/gi },
  // The password part of a connection string: postgres://user:PASSWORD@host
  { name: 'url-password', re: /(?<=:\/\/[^\s:\/@]+:)[^\s@\/]+(?=@)/g },
  { name: 'card', re: /\b\d(?:[ -]?\d){12,18}\b/g, check: luhn },
  { name: 'iban', re: /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b/g, check: ibanOk },
  { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
  // Phone numbers only in international form (+49 ...), to keep version numbers and ids out
  { name: 'phone', re: /\+\d{1,3}[ .-]?\(?\d{1,4}\)?(?:[ .-]?\d{2,4}){2,4}\b/g },
]

// Masks every match in one string and reports which rules hit
export function scrub(text: string): { text: string; found: string[] } {
  const found = new Set<string>()
  let out = text
  for (const rule of RULES) {
    out = out.replace(rule.re, (m: string) => {
      if (rule.check && !rule.check(m)) return m
      found.add(rule.name)
      if (rule.name === 'secret-value') {
        // Keep the key name, mask the value
        const cut = m.search(/[=:]/)
        return m.slice(0, cut + 1) + '[redacted:secret]'
      }
      return `[redacted:${rule.name}]`
    })
  }
  return { text: out, found: [...found] }
}

// Walks any JSON-like value and scrubs every string in it, keeping the shape
export function scrubDeep(value: unknown, found: Set<string>): unknown {
  if (typeof value === 'string') {
    const r = scrub(value)
    r.found.forEach(f => found.add(f))
    return r.text
  }
  if (Array.isArray(value)) return value.map(v => scrubDeep(v, found))
  if (value !== null && typeof value === 'object') {
    const copy: Record<string, unknown> = {}
    for (const [k, v] of Object.entries(value)) copy[k] = scrubDeep(v, found)
    return copy
  }
  return value
}

The hooks. Three of them, one per direction:

hooks/register.ts
import type { Register } from 'claude-code'
import { scrub, scrubDeep } from './patterns'

// Shell commands that talk to the network. Matched on the command text only.
const NETWORK_CMD = /\b(?:curl|wget|http|nc|scp|rsync|ssh|gh|aws|gcloud)\b/

export const register: Register = on => {
  // 1. Your prompt: mask what you pasted before Claude reads it
  on('prompt.submit', async ($, e, next) => {
    const { text, found } = scrub(e.text)
    if (found.length === 0) return next(e)
    $.ui.toast(`secret-scrub masked ${found.join(', ')} in your prompt`)
    return next({ ...e, text })
  })

  // 2. Outbound: refuse a call that would send sensitive data out,
  //    and tell Claude why, so it can do the job another way
  on('tool.call', { tool: [/^mcp__/, 'WebFetch', 'Bash'] }, async ($, e, next) => {
    const goesOut = e.tool !== 'Bash' || NETWORK_CMD.test(String(e.command ?? ''))
    if (!goesOut) return next(e)
    const { tool_use_id, ...args } = e
    const found = new Set<string>()
    scrubDeep(args, found)
    if (found.size === 0) return next(e)
    $.ui.log(`secret-scrub blocked ${e.tool}: ${[...found].join(', ')}`)
    return {
      deny:
        `secret-scrub: this call would send ${[...found].join(', ')} out of the session. ` +
        'Do not retry with the same value. Leave it out, use a placeholder, or reference ' +
        'an environment variable by name. If it truly has to be sent, ask the user to do it.',
    }
  }).catch(async () => ({
    // If the check itself fails, refuse the call instead of letting it through
    deny: 'secret-scrub could not check this call, so it was not run.',
  }))

  // 3. Tool results: mask what a file read, a command or an MCP tool returned
  on('tool.call', async ($, e, next) => {
    const ran = await next(e)
    if (ran.deny !== undefined || ran.isError) return ran
    try {
      const found = new Set<string>()
      const result = scrubDeep(ran.result, found)
      if (found.size === 0) return ran
      $.ui.log(`secret-scrub masked ${[...found].join(', ')} in ${e.tool} output`)
      // A fresh { result } without core's ref, so Claude reads the masked copy
      return { result: result as typeof ran.result }
    } catch {
      return { deny: 'secret-scrub could not check this output, so Claude does not see it.' }
    }
  })
}

What each part does:

  • The prompt hook masks your prompt before it enters the session and shows a short toast, so you know it happened. The message in the transcript shows the masked text.2
  • The outbound hook covers every MCP tool, WebFetch, and Bash commands that look like network calls. If the arguments hold anything sensitive, it returns { deny } without calling next, so the call never runs and no permission prompt appears. The reason is written as an instruction, because Claude reads it as the tool's result.2
  • The .catch on the outbound hook makes it fail closed. Without it, a hook that throws or times out is skipped and the call goes ahead.2
  • The result hook lets every tool run, then walks the result and masks every string in it, keeping its shape. It returns a fresh { result }. It does not pass back the object it got: when a hook returns that object unchanged, Claude Code uses its own copy of the result (from the Claude Code 2.1.287 type declarations).

Hooks in one module run in the order they were registered, so for an MCP call the outbound check runs first and the result is masked on the way back.2

Check it before you trust it

A guard that does nothing looks exactly like a guard that works, until the day it matters. Two commands help. claude plugin validate reads the mod the way Claude Code will and lists what it hooks and what it calls.4 This is the real output for this mod on v2.1.287:

terminal
$ claude plugin validate ./secret-scrub
Validating plugin manifest: ./secret-scrub/.claude-plugin/plugin.json
Validating hooks: ./secret-scrub/hooks/hooks.json
  ❯ ./register.ts hooks: prompt.submit, tool.call{tool=/"^mcp__"/|WebFetch|Bash}, tool.call
  ❯ ./register.ts calls: $.ui.log, $.ui.toast
✔ Validation passed

The calls: line is short on purpose. This mod reads no files, starts no processes, and makes no network requests. It only writes a dim line to the transcript and shows a toast. That is worth checking in any security mod you install, because a mod sees every prompt and every tool call.1

claude plugin test runs the .test.ts files against Claude Code's own engine, with stubs standing in for the tools.8 Our tests check the patterns, a pasted AWS key, a cat .env result, a Slack MCP call with a card number, and a clean Slack call that must go through. The fake key and card values are built at run time, and the card and IBAN are public test numbers:

tests/secret-scrub.test.ts
import { expect, test } from 'claude-code/testing'
import { scrub } from '../hooks/patterns'

// Fake values built at run time so no real-looking secret sits in the file
const AWS = 'AKIA' + 'Q'.repeat(16)
const CARD = '4111 1111 1111 1111'            // a published test number, passes Luhn
const BAD_CARD = '4111 1111 1111 1112'        // fails Luhn, stays
const IBAN = 'DE89 3704 0044 0532 0130 00'    // the standard example IBAN, passes mod 97

test('scrub masks known patterns and keeps look-alikes', async () => {
  expect(scrub(`key ${AWS}`).text).toBe('key [redacted:aws-key]')
  expect(scrub(`pay with ${CARD}`).text).toBe('pay with [redacted:card]')
  expect(scrub(`order ${BAD_CARD}`).found).toEqual([])
  expect(scrub(`to ${IBAN}`).text).toBe('to [redacted:iban]')
  expect(scrub('DB_PASSWORD=hunter2hunter2').text).toBe('DB_PASSWORD=[redacted:secret]')
  expect(scrub('mail anna@example.com').text).toBe('mail [redacted:email]')
  expect(scrub('version 1.2.3 build 20261002').found).toEqual([])
})

test('a pasted key is masked before Claude reads the prompt', async ($, on) => {
  on('ui.toast', () => ({ value: undefined }))
  on('prompt.submit', ($, e) => ({ text: e.text }))
  const out = await $.prompt.submit({ text: `why does ${AWS} fail?` } as any)
  expect(out.text).toBe('why does [redacted:aws-key] fail?')
})

test('a command that prints a .env comes back masked', async ($, on) => {
  on('ui.log', () => ({ value: undefined }))
  on('tool.call', () => ({ result: { stdout: `STRIPE_SECRET_KEY=sk_live_${'a'.repeat(24)}\nPORT=3000`, stderr: '', interrupted: false } }))
  const out = await $.tool.call({ tool: 'Bash', command: 'cat .env' })
  expect(out).toEqual({ result: { stdout: 'STRIPE_SECRET_KEY=[redacted:secret]\nPORT=3000', stderr: '', interrupted: false } })
})

test('an MCP call that would post a card number is refused with a reason', async ($, on) => {
  on('ui.log', () => ({ value: undefined }))
  let ran = false
  on('tool.call', () => { ran = true; return { result: 'posted' } })
  const out = await $.tool.call({ tool: 'mcp__slack__post_message', channel: 'support', text: `card ${CARD} declined` } as any)
  expect(ran).toBe(false)
  expect(out.deny).toMatch(/would send card out of the session/)
})

test('a clean MCP call goes through', async ($, on) => {
  on('tool.call', () => ({ result: 'posted' }))
  const out = await $.tool.call({ tool: 'mcp__slack__post_message', channel: 'support', text: 'deploy done' } as any)
  expect(out).toEqual({ result: 'posted' })
})
terminal
$ claude plugin test
tests/secret-scrub.test.ts:
(pass) scrub masks known patterns and keeps look-alikes
(pass) a pasted key is masked before Claude reads the prompt
(pass) a command that prints a .env comes back masked
(pass) an MCP call that would post a card number is refused with a reason
(pass) a clean MCP call goes through

 5 pass
 0 fail
Ran 5 tests across 1 file.

What Claude sees

Suppose Claude runs cat .env while debugging. The command runs as usual, but Claude reads the masked copy:

what Claude reads
STRIPE_SECRET_KEY=[redacted:secret]
DATABASE_URL=postgres://app:[redacted:url-password]@db.internal:5432/app
PORT=3000
SUPPORT_EMAIL=[redacted:email]

Later, Claude tries to post a summary to Slack with a customer's card number in it. The call does not run, and Claude reads this as the result:

what Claude reads
secret-scrub: this call would send card out of the session. Do not retry with
the same value. Leave it out, use a placeholder, or reference an environment
variable by name. If it truly has to be sent, ask the user to do it.

That is the point of a guard that blocks one call and gives a reason, instead of stopping the session. Claude still has the task, it knows what went wrong, and it can post the summary without the number. Whether it does depends on the model and the task, so watch the first few times.

One side effect to know about. If Claude reads a file with masked values and later edits it, its edit can contain the placeholder text, or fail to match the real line. Review diffs to files that hold secrets, or keep those files away from Claude entirely with a deny rule, as described below.

What it will not catch

This mod is a layer, not a guarantee. Be clear about where it stops:

  • Patterns miss things. Custom token formats, names and street addresses, a key split across two lines, base64 or URL encoded values, a password without a PASSWORD= label. Add rules for the formats your systems use.
  • It also masks things that are fine. An example email in documentation gets masked. So does the right side of a code line like API_KEY = process.env.API_KEY, which can confuse Claude when it edits that code. And the Bash check matches words like http or gh anywhere in the command, so a local grep for an http:// URL counts as outbound and is refused when it also holds, say, an email address. Tune the rules to your codebase.
  • What already went in stays in. Masking applies from the moment the mod loads. A key that reached the model in an earlier prompt or tool result cannot be recalled. If one did, rotate it.
  • It only sees what passes through hooks. The outbound check reads tool arguments. A test script that Claude runs can still open a network connection on its own, and the Bash check only matches the command text. A mod's own file and process calls are also not tool calls.4
  • It can be turned off. claude --safe-mode and "disableAllHooks": true in your own settings stop the mods you installed.1 Mods are also not sandboxed: they run with your permissions.1
  • Time limits. A hook gets 10 seconds of its own execution time per event.3 A huge tool result could in theory push past that. The outbound hook fails closed through .catch. The result hook catches its own errors, but a timeout after the tool ran leaves the raw result standing.2

Use it with what Claude Code already has

We found no built-in step in the Claude Code docs that scans prompts or tool output for secrets. The docs do describe controls that work well next to a redaction mod:

  • Read deny rules. A rule such as Read(./.env) or Read(./secrets/**) blocks Claude's file tools. It also blocks file commands Claude Code recognizes in Bash, such as cat, head and tail. It does not cover a script that opens the file itself.6 Better to never read a secret than to mask it after.
  • The sandbox. For operating system level isolation of files and network for Bash commands, the docs point to sandboxing.67
  • Network approval. Commands like curl and wget are not auto-approved by default, and you can add them to permissions.deny.7
  • For teams. An administrator can ship the mod through managed settings so it counts as the organization's, run it before every user's mod with prependPlugins, and keep users' own mods out with the built-in guard's allowManagedModsOnly option.4 Note that --safe-mode still turns installed mods off, the organization's included.4

On the data side, Anthropic documents limited retention periods, restricted access to session data, and training controls in its privacy safeguards.7 Those cover what happens after data reaches Anthropic. The mod is about sending less of it in the first place.

One place this matters for us: logs. Logs often carry tokens and customer emails that nobody meant to log. Fixter is monitoring for teams that build with coding agents: you send your logs and traces over standard OpenTelemetry, and Fixter finds the issues and bugs in your system. When you pull those into Claude Code over MCP, the results pass through tool.call like any other MCP tool, so the same mod masks them before Claude reads them.

Key takeaways

  • A mod can rewrite your prompt before it enters the session (prompt.submit)
  • A tool.call hook can refuse a call with a reason, or let it run and hand Claude a masked copy of the result
  • MCP tools and subagents' tool calls go through the same tool.call event
  • Block one call with a reason Claude can act on, do not stop the session
  • Patterns miss things and data already sent cannot be recalled: pair the mod with Read deny rules and the sandbox

Frequently asked questions

Can a Claude Code mod redact secrets before Claude sees them?

Yes, at three points. A prompt.submit hook can rewrite the prompt you send before it enters the session. A tool.call hook can let a tool run, then hand Claude a masked copy of the result instead of the real one. And the same tool.call hook can refuse a call before it runs. Anything that reaches Claude some other way, or reached it before the mod loaded, is not covered.

Can a mod stop Claude from sending an API key to Slack or another MCP server?

Yes. MCP tool calls pass through the tool.call event like built-in tools, with names such as mcp__slack__post_message, and a matcher can catch them all with a regular expression like /^mcp__/. The hook checks the arguments and returns { deny: reason } without calling next, so the call never runs and Claude reads the reason as the tool's result.

Can a settings hook do the same thing without a mod?

Most of it. A PreToolUse hook can deny a call with a reason Claude sees, and a PostToolUse hook can replace the tool output Claude reads. A UserPromptSubmit hook can block a prompt or add context, but it cannot rewrite the prompt text. A mod can, and it keeps all three checks in one tested file that runs inside Claude Code.

Does Claude Code redact secrets on its own?

We found no built-in step in the Claude Code docs that scans prompts or tool output for secrets. What the docs do describe is Read deny rules such as Read(./.env), which keep Claude's file tools and common commands like cat away from a file, a sandbox for operating system level isolation of Bash, and approval for network commands like curl. A redaction mod is a layer on top of those.

Is pattern-based redaction enough to keep data safe?

No. Patterns catch keys with known prefixes, card numbers that pass a Luhn check, valid IBANs and similar shapes. They miss custom tokens, names, addresses, values split across lines or encoded, and anything a program sends without going through a tool call. Treat a redaction mod as one layer next to deny rules, a sandbox, and keeping real secrets out of the repo.