Docs · Hiding secrets
Hiding secrets
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 a secret stops
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. Nothing bounces back to you, nothing asks you to confirm, and your session does not stop. 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
Trying 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.
Key column will read 2c458c35553a. A
fingerprint is a plain SHA-256 of the value, truncated, so the lines above produce those
same twelve characters for you as for anybody else who runs them unchanged. The feed is
built on that property: enough to recognise one credential across days, people and
repositories, not enough to reconstruct it.
Trying 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.
In the feed it lands as the same rule and the same fingerprint,
2c458c35553a, because a fingerprint is of the value and of nothing else.
What differs is Who. 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. For the same reason, its spend lands on your own budget
and not on a machine account's.
Time · Who · Rule · Key · Count, so a row says who sent a credential
and not which app they were in. The UA column that answers that (a
terminal glyph for Claude Code, a monitor for Desktop) sits on the Requests tab beside it,
so telling the two apart means matching a detection against the request log by time.
For owners
Turning it 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 Secrets feed
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
| Time | Who | Rule | Key | Count |
|---|---|---|---|---|
| 15:12:45 | dev@yourcompany.com | jwt | 2c458c35553a | 1 |
Count is how many distinct values that rule
matched in the body; Key fingerprints the first of them. 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. |
The rules
gitleaks' own gitleaks.toml, compiled into the proxy by a script and
vendored there so nothing is fetched over the network. That gives
219 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.
Two 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.
One pass does the work of 219: 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 it deliberately does 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. The one refusal is a secret that was found and could not be removed, which answers 422 selan: request blocked — it carries a secret that could not be safely removed. A 422 rather than a 503 on purpose: an SDK retries a 503, and retrying this one would send the same secret again. |
| It does not scan model output | Ever, in any mode. |
| It fails open | If a scan never ran (a timeout, a dead worker), the request is forwarded unscanned and an error is logged. Refusing buys nothing there, since a scan that never ran found no secret to refuse over, and refusing took live sessions down every turn instead. Stated plainly: someone who can reliably make a scan exceed a deadline set in proportion to their own body has a bypass. |
| 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.