selan.ai

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]

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

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

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

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.

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.

The Secrets tab has no client column. Its five are 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 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.

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

TimeWhoRuleKeyCount
15:12:45 dev@yourcompany.com jwt 2c458c35553a 1
One row per rule per request. 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 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.

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 yaml emits 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.
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 the credential it fingerprints, not as an incident already closed.

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.