Writing regexes that catch API keys without false positives
The naive approach to finding API keys with regex goes like this: match
something that looks random. [A-Za-z0-9]{32} catches the real key — and
also catches UUIDs, hashes, build IDs, and half the identifiers in your
codebase. The naive approach to avoiding false positives goes like this:
match the exact known format, sk-[A-Za-z0-9]{20}, which catches nothing
once the vendor changes the format. The hard part of writing secret regexes
is that both failure modes are expensive: too many false positives and the
scan gets ignored; too few and the leak is real.
Structure the pattern like the real thing
Real secrets have structure: a prefix that identifies the vendor and a random payload. Match that structure instead of “randomness”:
- Anchor on the prefix.
sk-[A-Za-z0-9]{20,40}beats a bare character class. The prefix is what makes the match specific — and it’s also what breaks when a vendor changes format, which is why prefix knowledge needs maintenance. - Respect separators. Keys often appear with delimiters (
-,_,.).[A-Za-z0-9_-]{32}includes the delimiter so the match doesn’t split the key in half. - Use word boundaries, not line anchors.
\baround the pattern prevents matching inside longer identifiers — you don’t want to flag the tail ofmy_sk_abc123. In RE2,\band\dare available; lookahead and backreferences are not, so design accordingly.
Add entropy as a second opinion
Regex tells you the shape matches; entropy tells you the content is random.
Together they are far stronger than either alone. A 40-character alphanumeric
string that starts with sk- and scores high on Shannon entropy is a key;
the same string reading aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is not.
The standard pipeline is: keyword pre-filter to skip cheap non-matches,
regex to establish format, entropy with a threshold (3.5 is a common
default) to confirm randomness. This also defuses the classic false
positive: a UUID is 36 characters of hex and dashes, which looks like a key —
but a rule anchored on a vendor prefix won’t touch it.
Practical patterns worth stealing
Some patterns that hold up in production scans:
# AWS access key
\bAKIA[0-9A-Z]{16}\b
# Generic prefixed secret key
\b(sk|pk|rk|tk)_[A-Za-z0-9]{20,60}\b
# Private key header
-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----
# Slack webhook
\bhttps://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+
Each one is anchored, delimited, and specific enough that a hash or a UUID won’t match. If you’re writing your own, extract the vendor’s documented format from their docs and build a fixture test — one real key, one UUID, one hash, one random English sentence — before trusting it.
Tuning beats perfecting
No rule set reaches zero false positives, so design the pipeline for
triage, not purity. Keep per-rule controls: a log mode for noisy but
plausible rules, a mask mode for high-confidence ones, and the ability to
disable a rule without deleting it. Let rules reload without restarting the
scanner. When a rule fires on something that isn’t a secret, that’s a
signal — either the rule needs narrowing or the codebase has a naming
collision worth knowing about.
One more layer is worth having: match known values directly. A pattern says “this looks like a secret”; a known-value match says “this is one of our secrets” — zero false positives by construction. Run both, and you get breadth from patterns and precision from the value list.
If you’re building your own scanning layer, the pipeline in
trustless is a useful reference:
40 gitleaks-derived rules (MIT, attribution in the NOTICE file) behind a
keyword pre-filter, RE2 regex, and Shannon entropy at a default threshold of
3.5, with per-rule log/mask modes, a custom rules_file, hot reload on
SIGHUP, and an atomic config swap so a bad rule file can’t disable scanning.
The same rule set feeds its DLP reverse proxy and its retroactive scrub
commands.
Which false positive in your last secret scan did you fix by changing the rule — and which one did you fix by ignoring the scan?