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]
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"
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.
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.
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.
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. |
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
| Rule | Session | Who | Last seen | Requests |
|---|---|---|---|---|
| github-pat | 7c1e0b9a | dev@yourcompany.com | 15:12:45 | 3 |
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 scope | Why |
|---|---|
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 scope | Why |
|---|---|
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 yamlemits 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. |
More
- Trust: what a request leaves behind, what is stored, and what is never stored.
- What an owner sets: this switch beside the other company-wide ones.
- Secrets: why a credential should never reach the provider.
- How Claude Code and Codex run without API keys on laptops: the provider keys themselves, kept off every laptop.