selan.ai
Security & cost

Docs · Hiding secrets

Hide secrets from the model in Claude Code

A credential pasted into a prompt, or read out of a file by a tool, is gone before the request reaches the model. It is replaced in place, and the request still goes through. It is a company setting, and it is off until an owner turns it on. This page covers what it does, what it does not do, and how to see it happen on your own machine.

What happens

Where does a secret stop?

Without the setting, a request streams: Selan leases one of your company's credentials and pipes your body to the provider without holding it. With it on, the request stops at the proxy. The body is buffered, parsed, and every string inside it swept for credentials using gitleaks' own rule set. Each match is replaced before a byte goes upstream.

Then the request goes on. It redacts and forwards. It never blocks over a secret. Nothing bounces back to you, nothing asks you to confirm, and your session does not stop. The one refusal is when the scanner cannot be reached: then the request is not sent at all (see limits). The developer sees no warning at all, which is why the first sign of it is usually the model itself remarking that it was handed a placeholder.

test token: eyJhbGciOiJIUzI1NiIsInR5… test token: [REDACTED: jwt]

The rule that fired is in the marker, so whoever reads the request afterwards (you, or the model) can tell what was taken out. It contains no character JSON escapes, so a substitution cannot invalidate the document the secret sat in, and only the pattern's own capture group is replaced: the quote or semicolon a rule anchored on stays where it was.

See it yourself

How do I test it in Claude Code?

You do not need a leaked credential to test this, and you should not use one. Assemble a JWT that signs nothing:

b64() { printf %s "$1" | base64 | tr -d '=\n' | tr '+/' '-_'; }

head=$(b64 '{"alg":"HS256","typ":"JWT"}')
body=$(b64 '{"sub":"local-example","name":"not a real token"}')
JWT="$head.$body.$(b64 'this-signature-signs-nothing')"

The result is a syntactically real JWT: three base64url segments and a header naming HS256. It authenticates against nothing. Send it through:

selan claude -p "Read this token back to me verbatim and say nothing else: $JWT"
> Read this token back to me verbatim and say nothing else: eyJhbGciOiJIUzI1NiIsInR5…
The value I received is [REDACTED: jwt] — a placeholder, not a token. Something between you and me removed it before it got here.
The model cannot read back what it never received. Exactly what it says varies. The one thing that does not is that the token is not in the answer.

This doubles as the check for whether the setting is on: if the model reads the token back to you cheerfully, your company has not turned scanning on and nothing is being swept.

Note what did and did not happen. The token did leave your machine. It reached the Selan proxy, which is where the scan runs. It did not reach Anthropic, and it is not written down anywhere: the body is held in memory for the length of the request and is never stored. See Trust for what is.

This token is removed but not reported. Its signature decodes to readable text, so Selan reads it as a value somebody typed for a test, not a credential somebody leaked: it is redacted all the same, and it stays out of the Secrets feed. The same goes for jwt.io's sample token, a subject such as user@example.com and values like 12345678. A real credential does land in the feed.

How do I test it in Claude Desktop?

Claude Desktop is pointed at the same proxy, which is what setting it up does, so its requests take the same path, lease the same credential and get the same sweep. The setting is per company, not per client. There is nothing to switch on a second time for Desktop, and no way to have it on for one client and off for the other.

There is no shell inside Desktop, so put the same throwaway token on the clipboard instead:

printf %s "$JWT" | pbcopy            # macOS
printf %s "$JWT" | xclip -sel clip   # Linux

Then paste it into a Desktop conversation and ask for it back.

test token: eyJhbGciOiJIUzI1NiIsInR5…

The value itself is [REDACTED: jwt] — a placeholder, not an actual token. Some layer between you and me stripped the real credential out, so there is nothing functional there for me to inspect, decode or use.

The same substitution, in the other client. Worth noticing what the model can say here: it is reading the marker, so it knows something was taken out and which rule took it. It simply never had the value.

A real credential sent from Desktop lands in the feed the same way as one from Claude Code. The credential Desktop holds is a device token, and its principal is your own email, so a detection from Desktop is attributed to you personally in Who. For the same reason, its spend lands on your own budget and not on a machine account's.

The Secrets tab says which app sent it. Its UA column shows a terminal glyph for Claude Code and a monitor for Desktop, and Agent marks a detection that came from a remote agent's run.

For owners

How do I turn secret scanning on?

Settings → Security → Secret scanning, in control.selan.ai. It saves the moment you click it. There is no Save button, so there is no way to leave that page believing scanning is on when it is not.

How it behaves
Default Off, for every company. A company that has never opened the page has not agreed to having its requests held and inspected, so absent reads as off, not as not-yet-configured.
Who can change it Owners. Everyone can read it: “are my requests being inspected” is a question anyone spending against the workspace is entitled to an answer to, and turning it off is the dangerous direction.
Audit Both edges are logged, on and off. Who turned it off and when is the question an incident asks first.
What the CLI is told Nothing. The launch settings Selan hands Claude Code deliberately omit this one, so a developer's own machine gets no signal about whether its requests are being swept. The setting itself is readable by every member in the console, which is where the transparency lives.
The cost is latency, and it is the whole reason this is a switch. To scan a body the proxy has to hold it, so it cannot start dialling upstream while your request is still arriving. A long conversation does not pay for that twice: a scan hands back the prefix it cleared, and the next turn walks only the messages that are new or already known dirty. On a 4.36 MB transcript, 1145 ms of rescanning becomes 14 ms.

Where do I see what was caught?

Detections land in Logs → Secrets, beside the request log and sharing its range switcher and pager. The count badge is visible from the Requests tab, so a leak does not wait to be found by whoever thinks to change tabs.

Logs RequestsSecrets 104

RuleSessionWhoLast seenRequests
github-pat 7c1e0b9a dev@yourcompany.com 15:12:45 3
One row per credential per person, whichever rule caught it. Requests is how many requests carried it in the range, and Session is the session it last arrived in. The tab also has UA, Agent and Model columns, and a chart above the table counts requests that carried a credential. A member with read access sees only their own detections.

A detection carries no secret material, ever. What it records is the rule that fired, how many values it matched, and twelve hex characters of a hash. Not the value, not a prefix, not a first-four-and-last-four preview. A secret scanner whose audit trail is itself a store of secrets is a worse outcome than not scanning at all.

With scanning off the feed does not report an all-clear, because that screen is the one somebody opens to check for leaks:

Secret scanning is off — Requests are not being checked for credentials. This is not the same as having found none.

The detail

What is scanned, and what is not?

Request bodies, on the way upstream. Nothing else is scanned, and the boundaries inside that scope are deliberate, not incidental.

In scopeWhy
Every top-level field: system, tools, metadata The scanner works on a copy of the body with the messages emptied out. It does not walk a fixed list of fields, so a field Anthropic adds later is scanned by default instead of silently skipped.
Every message you or a tool wrote, including tool_result blocks This is how a secret usually arrives. Nobody types it; something reads it: cat .env, kubectl get secret -o yaml.
tool_use blocks inside the model's own messages Model-authored, but these end up executed as commands, not read as prose.
Object keys, not only values A secret used as an object key is still a secret leaving the building.
Out of scopeWhy
The model's prose: text, thinking It is text the provider generated and already holds, so rewriting our copy protects nobody from it. It is also unsafe: a thinking block carries a signature over exactly that text, and an earlier version that rewrote inside one produced Invalid `signature` in `thinking` block on every other turn in production.
Response bodies Nothing on the way back is ever scanned. What the model says is not inspected, in either direction.

Which rules detect a secret?

gitleaks' own gitleaks.toml, compiled into the proxy by a script and vendored there so nothing is fetched over the network. That gives 215 rules, with upstream's allowlists carried across, so AWS's published example key does not fire the way it did before those were honoured.

The patterns run on RE2, not the built-in engine. No backtracking, so match time stays linear on a body somebody else chose the contents of. On top of the patterns sit two things a pattern cannot do on its own:

  • An entropy floor, per rule. [a-z0-9]{32} matches a checksum as readily as a key; entropy is what separates them.
  • A base64 gate. Runs of 40 or more base64 characters are decoded and re-tested, because kubectl get secret -o yaml emits base64 by design.

Ten rules are deliberately left out. generic-api-key matches key = <any ten characters> and fires constantly on ordinary source; kubernetes-secret-yaml keys on the bare word “secret” and fires on prose. A scanner that mangles ordinary work gets switched off, and a scanner that is switched off protects nobody. The eight *-client-id rules are left out too: an OAuth client id is public by design. Four rules are added for the tokens Selan itself issues.

One pass does the work of 215: every rule's keywords are merged into a single search, and only the rules whose keywords actually appear are run. A clean 130 KB body costs about half a millisecond.

What does it deliberately not do?

It does not block Claude Code resends the entire conversation every turn, so refusing a request over a secret that arrived in a tool result would brick that conversation permanently: every retry resends it. When a secret survives being cut out of its string, the redaction widens instead: the whole string, then the whole message, then the whole body, until nothing of it is left. The request is still forwarded.
It does not scan model output Ever, in any mode.
It does not forward unscanned If the scanner cannot be reached or answers too late, the request is refused with 503 selan: request blocked — the secret scanner could not be reached, so nothing was sent. A 503 on purpose: nothing was found, so a retry is the right thing for the client to do.
It has one knob On or off, per company. No blocking mode, no per-rule configuration, no allowlist screen, no notification.
A redaction is not a rotation. It means the provider never received that value. It does not mean the value is still good. It was in a shell, a file or a CI log before it was in a prompt, and this saw it late. Treat a row in the Secrets feed as a ticket to rotate that credential, not as an incident already closed.

More