Plaintext Secret Scanning
A hardcoded API key in a Reactor works. It also puts a live credential into a field designed to hold code, where it is stored in plaintext alongside your resource, returned by read APIs, and copied wherever that resource is replicated. Rotating the credential then means editing and redeploying source.
Basis Theory scans Reactor and Proxy Transform source for plaintext secrets and rejects writes that contain them. Secrets belong in the encrypted configuration object, which is stored separately and injected into your function at invocation time.
What Gets Scanned
Basis Theory scans the code property on every write to:
- Reactors, on create, update, and patch.
- Proxy request and response code transforms, on proxy create, update, and patch.
Only writes are gated. Source that is already deployed keeps running regardless of what it contains, and scanning never affects Reactor or Proxy invocation.
What the Scan Detects
Detections are grouped into five categories. The category name is what appears in the validation error and in the validate response.
| Category | What it matches |
|---|---|
private key block | A complete PEM private key, from a -----BEGIN ... PRIVATE KEY----- marker through the matching -----END----- marker, carrying at least 40 characters of Base64 payload. |
encoded private key material | A Base64 run of 64 characters or more that decodes to a DER private key structure (PKCS#8, PKCS#1, SEC1 elliptic curve, or PKCS#12), with no PEM markers around it. |
known credential format | A credential whose shape identifies its issuer, such as an AWS access key ID, a Stripe secret key, a GitHub token, a Basis Theory private or management API key, or a signed JWT. |
static authorization credential | A literal credential following Basic or Bearer, anywhere in the source rather than only in an assignment. |
credential assignment | A string literal of eight characters or more assigned or compared to an identifier whose name ends in a credential keyword, such as apiKey, password, client_secret, passphrase, or authorization. |
Four of the five appear in the source below. The fifth, encoded private key material, is the same key content with its PEM markers stripped off.
module.exports = async function (req) {
const apiKey = "9f8d7a6b5c4e3f2a1b0c";
const signingKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
-----END PRIVATE KEY-----`;
const response = await fetch("https://api.example.com/charges", {
headers: {
Authorization: "Bearer 8f2b41c9d7e63a05b1f4c8e2d9a76b30",
"X-Provider-Key": "sk_live_51H8xK2LmQpRs7TvW",
},
});
};
What the Scan Allows
Detection targets credentials that were pasted into source. Patterns that reference a secret without containing one are deliberately not flagged, and neither are values that are obviously not credentials.
Configuration and environment references. Anything reading from req.configuration, args.configuration, context.configuration, configuration., or process.env is the recommended pattern and is never flagged.
Interpolation. A value containing ${...} or {{...}} is treated as injected at runtime. This covers template literals and detokenization expressions.
Prefix concatenation onto a configuration reference. A literal joined to a configuration reference, such as "key-prefix-" + args.configuration.SUFFIX, is a prefix of an injected secret rather than a secret. This is what keeps the common "Bearer " + token header idiom working: the scheme word is a prefix, and the credential itself arrives from elsewhere.
Public PEM blocks. Certificates, certificate requests, X.509 CRLs, and public keys are not secrets. Matches occurring inside a correctly labeled public PEM body are suppressed, so an embedded X.509 certificate will not trip detection on coincidental Base64 content.
Placeholders. Angle-bracket forms like <API_KEY>, constant-style names like YOUR_API_KEY, low-variety filler like xxxxxxxx, and any value containing markers such as example, sample, placeholder, dummy, todo, or test.
Prose in a credential-named variable. A value with interior whitespace is a message, not an opaque token, so const tokenError = "token was not provided" is not flagged.
module.exports = async function (req) {
const { SERVICE_API_KEY, ACCOUNT_ID } = req.configuration;
const response = await fetch(`https://api.example.com/${ACCOUNT_ID}/charges`, {
headers: {
Authorization: "Bearer " + SERVICE_API_KEY,
"X-Signing-Key": "req-prefix-" + req.configuration.SUFFIX,
},
body: JSON.stringify({
number: "{{ " + req.args.token_id + " | json: \"$.number\" }}",
}),
});
};
When a Legitimate Value Is Flagged
The scan is conservative, and a non-secret literal assigned to a credential-named variable is still a detection. The most common case is a hardcoded Basis Theory token ID:
const token = "45c124e7-6ab2-4899-b4d9-1388b0ba9d04"; // flagged
const tokenId = "45c124e7-6ab2-4899-b4d9-1388b0ba9d04"; // not flagged
The keyword has to sit at the end of the identifier name for the assignment to match, so naming the variable for what it holds resolves it. If a value is genuinely not a secret and you cannot rename around the detection, contact us.
When a Write Is Rejected
A rejected create, update, or patch returns 400 with a validation error on the offending property. The message names the detected categories and nothing else:
{
"errors": {
"code": [
"'code' must not contain plaintext secrets. Detected: credential assignment. Provide sensitive values through encrypted configuration instead. See https://developers.basistheory.com/docs/concepts/runtimes/secret-scanning to identify the flagged lines."
]
}
}
The matched text is never echoed back, and neither are its offsets. To find out which lines tripped detection, send the same source to the validate endpoint.
Checking Source Before You Deploy
Validate reports what the scan finds in a block of source without creating or updating anything. It runs the same detector as the write path, so a result of no detections means the same source will pass the gate.
Permissions
Any one of the following, on a Management API key:
reactor:create
reactor:update
proxy:create
proxy:update
Request
code is required and accepts up to 50,000 characters, matching the limit on Reactor and Proxy Transform source.
curl 'https://api.basistheory.com/function-source/validate' \
-X 'POST' \
-H 'BT-API-KEY: <MANAGEMENT_API_KEY>' \
-H 'Content-Type: application/json' \
--data '{
"code": "const apiKey = \"9f8d7a6b5c4e3f2a1b0c\";\nmodule.exports = async function (req) {};"
}'
Response
{
"detected": true,
"categories": ["credential assignment"],
"detections": [
{
"category": "credential assignment",
"start": 16,
"length": 20
}
],
"redacted_code": "const apiKey = \"[REDACTED]\";\nmodule.exports = async function (req) {};"
}
| Attribute | Type | Description |
|---|---|---|
detected | boolean | Whether any plaintext secret was found. false means this source passes the gate. |
categories | array | The distinct categories found. |
detections | array | One entry per match, ordered by position. |
detections[].category | string | The category this match belongs to. |
detections[].start | integer | Zero-based offset of the match, in UTF-16 code units, into the submitted code. |
detections[].length | integer | Length of the match, in UTF-16 code units. |
redacted_code | string | The submitted source with every detected span replaced by [REDACTED]. |
The response never contains the matched text. redacted_code exists so you can see the flagged lines in context.
Offsets index the source you submitted, not redacted_code. Detected ranges are replaced by the fixed 10-character marker. Overlapping ranges are merged before redaction, so redacted_code can contain fewer markers than detections. Replacing a range with a marker of a different length shifts every later position. Slice against your original code.
Offsets are counted in UTF-16 code units rather than Unicode code points. Characters outside the Basic Multilingual Plane, such as emoji, count as two units each. This matches string indexing in JavaScript, C#, and Java. In a language that indexes by code point or by byte, such as Python, Go, or Rust, convert before slicing.
This endpoint is not yet available in the Basis Theory SDKs. Call it over HTTP.
Moving Secrets Into Configuration
Both Reactors and Pre-Configured Proxies accept a configuration object. Basis Theory encrypts each value and injects it into your function at invocation time, so the secret never enters source.
module.exports = async function (req) {
const apiKey = "9f8d7a6b5c4e3f2a1b0c";
// ...
};
module.exports = async function (req) {
const apiKey = req.configuration.SERVICE_API_KEY;
// ...
};
Supply the value alongside the source when you write the resource:
{
"name": "My Reactor",
"code": "module.exports = async function (req) { const apiKey = req.configuration.SERVICE_API_KEY; };",
"configuration": {
"SERVICE_API_KEY": "9f8d7a6b5c4e3f2a1b0c"
}
}
See Reactor configuration and the Pre-Configured Proxies API for the full request shapes.
Moving a credential out of source does not undo its exposure. A secret that was ever committed to function source should be rotated at its issuer, then stored in configuration as a new value.
Updating an Existing Resource
A full PUT is evaluated on the source in the request body, not on the difference from what is stored. Resending unchanged source that contains a secret is rejected even when your actual change is unrelated.
Two ways to avoid this:
- Make one atomic update that replaces
codeandconfigurationtogether. - Use
PATCHand omitcodeentirely when the source is not what you are changing.
Detection Scope
The scan protects against accidentally committing a credential to source. It is not an adversarial control, and it does not attempt to be one. Anyone who controls the source can defeat a static scan through encoding, splitting a value across expressions, indirection, or assembling a credential at runtime. Exclusions are tuned to keep legitimate patterns working rather than to close every bypass.
Treat a clean validate result as confirmation that your source will pass the gate, not as proof that it holds no secrets.