Skip to main content

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.

CategoryWhat it matches
private key blockA 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 materialA 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 formatA 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 credentialA literal credential following Basic or Bearer, anywhere in the source rather than only in an assignment.
credential assignmentA 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.

Source that will be rejected
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.

Source that will be accepted
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:

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

POST
https://api.basistheory.com/function-source/validate
Copy

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.

Validate Function 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

Response
{
"detected": true,
"categories": ["credential assignment"],
"detections": [
{
"category": "credential assignment",
"start": 16,
"length": 20
}
],
"redacted_code": "const apiKey = \"[REDACTED]\";\nmodule.exports = async function (req) {};"
}
AttributeTypeDescription
detectedbooleanWhether any plaintext secret was found. false means this source passes the gate.
categoriesarrayThe distinct categories found.
detectionsarrayOne entry per match, ordered by position.
detections[].categorystringThe category this match belongs to.
detections[].startintegerZero-based offset of the match, in UTF-16 code units, into the submitted code.
detections[].lengthintegerLength of the match, in UTF-16 code units.
redacted_codestringThe 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.

Before
module.exports = async function (req) {
const apiKey = "9f8d7a6b5c4e3f2a1b0c";
// ...
};
After
module.exports = async function (req) {
const apiKey = req.configuration.SERVICE_API_KEY;
// ...
};

Supply the value alongside the source when you write the resource:

Request body
{
"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 code and configuration together.
  • Use PATCH and omit code entirely 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.