Time: ~2 hours · Difficulty: Beginner to intermediate · You need: a terminal with grep and python3; ripgrep is useful but optional
Why regex is a security topic and not a text-editing trick
Almost everyone in this field learns just enough regular expression syntax to search a log file, and then stops. That is a reasonable place to stop if a regex is only ever a search. It is a bad place to stop the moment a regex becomes a control, and in cloud security it becomes a control constantly.
A regex decides whether a URL is on your allowlist. A regex decides whether a string in a commit is a leaked credential. A regex is what a Sigma rule compiles down to inside your SIEM, what a WAF matches request bodies against, what a CloudWatch Logs metric filter counts, and what a DLP scanner calls a Social Security number. In every one of those places a regex that is subtly too broad is an alert storm or a false block, and a regex that is subtly too narrow is a detection that silently never fires.
The skill this page is trying to build is not "write a regex". It is the habit of asking two questions about every pattern you meet: what does this match that I did not intend, and what does it fail to match that I did intend? Both of those failures are quiet. Neither one produces an error message.
On this page
Two engines, two sets of rules
Before any syntax, know which family of engine you are writing for, because it changes both what you are allowed to write and what happens under attack.
- Backtracking engines (PCRE, Perl, Python's
re, Java, .NET, JavaScript, most SIEM query languages) try alternatives and reverse out of dead ends. They support lookahead, lookbehind, and backreferences. They can also take exponential time on a hostile input. - Finite-automaton engines (RE2, Go's
regexp, the Rustregexcrate, and therefore ripgrep by default) match in time linear in the input length, always. The price is that lookahead, lookbehind, and backreferences do not exist, because they cannot be expressed in the model.
This is why a pattern copied from a blog post into a Go service fails to compile, and why the same pattern copied into a Python validator is a denial-of-service risk. Google built RE2 specifically so that user-supplied patterns could be run safely, and services that accept regex from users (log platforms, WAF rule builders) almost always use it for exactly that reason.
The two failure directions, concretely
# TOO BROAD - the dot is unescaped, so it matches ANY character https://.*.example.com # matches https://evilXexampleYcom # TOO BROAD - no anchors, so the match can sit anywhere in the string https://.*\.example\.com # matches https://evil.com/?x=.example.com # TOO NARROW - anchored and escaped, but assumes lowercase and no port ^https://[a-z]+\.example\.com$ # misses https://API.example.com:8443
All three of these are plausible-looking, and the first two are the shape of a real server-side request forgery allowlist bypass. The third is the shape of a detection rule that quietly covers less than the author thought.
Walk-through
Build a scratch directory first. Every step below runs against these files.
mkdir -p ~/regex-lab && cd ~/regex-lab cat > sample.log <<'EOF' 2026-08-20T14:03:11Z user=alice ip=203.0.113.14 action=AssumeRole result=success 2026-08-20T14:03:12Z user=bob ip=198.51.100.7 action=GetObject result=denied 2026-08-20T14:05:40Z user=svc-deploy ip=10.0.4.19 action=PutBucketPolicy result=success 2026-08-20T14:06:02Z user=mallory ip=203.0.113.99 action=ListBuckets result=denied 2026-08-20T14:06:03Z user=mallory ip=203.0.113.99 action=ListBuckets result=denied EOF cat > secrets.txt <<'EOF' aws_access_key_id = AKIAIOSFODNN7EXAMPLE temp_key = ASIAIOSFODNN7EXAMPLE not_a_key = XAKIAIOSFODNN7EXAMPLEX doc_reference = "the AKIA prefix identifies long-term keys" EOF
1. Anchors, and why they are a security control
# -E = extended regex (POSIX ERE). Without -E, grep needs backslashes # in front of +, ?, |, (, and ) - a classic source of "why doesn't this work". grep -E 'denied' sample.log # substring match, anywhere on the line grep -E '^2026-08-20T14:06' sample.log # ^ pins to start of line grep -E 'success$' sample.log # $ pins to end of line
In a search, an unanchored pattern is a convenience. In a validator, an unanchored pattern is a bug, because "contains" is not "is". Prove it to yourself:
python3 - <<'PY'
import re
allow = re.compile(r'https://.*\.example\.com') # no anchors
for url in ["https://api.example.com",
"https://evil.com/?redirect=.example.com",
"https://api.example.com.attacker.net"]:
print(bool(allow.search(url)), url)
PYAll three come back True. The second and third are the ones that get written up as an incident.
2. The Python anchor that is not an anchor
Anchoring both ends is necessary and, in Python specifically, not sufficient. $ matches at the end of the string or immediately before a trailing newline.
python3 - <<'PY'
import re
p = re.compile(r'^[a-z0-9-]+$') # "a simple hostname label"
print(bool(p.match("prod-web"))) # True - fine
print(bool(p.match("prod-web\n"))) # True - surprising
print(bool(p.match("prod-web\nrm -rf /")))# False - the dot rule saves you here
q = re.compile(r'\A[a-z0-9-]+\Z') # \A and \Z admit no newline at all
print(bool(q.match("prod-web\n"))) # False - what you meant
PYWhether the trailing newline matters depends entirely on what consumes the value afterwards. If it lands in a header, a log line, or a generated config file, it matters a great deal. Use \A and \Z in Python when the regex is a validator rather than a search.
3. Character classes, quantifiers, and greed
# Extract every source IP. [0-9]{1,3} = one to three digits.
grep -oE '\bip=[0-9]{1,3}(\.[0-9]{1,3}){3}\b' sample.log
# -o prints only the matched part rather than the whole line.
# \b is a word boundary: a zero-width assertion between a word
# character and a non-word character. It is what stops the pattern
# matching the middle of a longer token.Quantifiers are greedy by default: they take as much as they can and then give back only if the rest of the pattern fails. Adding ? makes them lazy, taking as little as possible. On a log line with more than one delimiter, the difference is the whole result:
python3 - <<'PY' import re line = 'user=alice action=AssumeRole result=success' print(re.search(r'user=(.*) ', line).group(1)) # greedy: 'alice action=AssumeRole' print(re.search(r'user=(.*?) ', line).group(1)) # lazy: 'alice' print(re.search(r'user=([^ ]+)', line).group(1)) # explicit: 'alice' <-- prefer this PY
The third form is the one to reach for. "Everything up to the next space" stated as [^ ]+ is unambiguous, is immune to a later change in the rest of the pattern, and does not depend on the reader remembering greed rules at 2am.
4. Groups, captures, and named captures
python3 - <<'PY'
import re, collections
pat = re.compile(
r'^(?P<ts>\S+) user=(?P<user>[^ ]+) ip=(?P<ip>[^ ]+) '
r'action=(?P<action>[^ ]+) result=(?P<result>\w+)$')
denials = collections.Counter()
for line in open('sample.log'):
m = pat.match(line.strip())
if m and m['result'] == 'denied':
denials[(m['user'], m['ip'])] += 1
print(denials) # repeated denials by principal and source IP
PY(?P<name>...) is a named capture group. (?:...) groups without capturing, which you want whenever a group exists only so a quantifier can apply to it. Naming groups is the difference between a parser you can read next year and a wall of group(4).
5. Write a credential scanner
AWS access key IDs are 20 characters: a four-character prefix plus 16 uppercase alphanumerics. AKIA is a long-term IAM user key. ASIA is a temporary STS credential.
# First attempt - finds the keys, and also finds the two things that are not keys
grep -oE '(AKIA|ASIA)[A-Z0-9]{16}' secrets.txt
# Word boundaries reject the embedded case
grep -oE '\b(AKIA|ASIA)[A-Z0-9]{16}\b' secrets.txtRun both. The first matches inside XAKIAIOSFODNN7EXAMPLEX, because nothing said the match had to start at a token boundary. The second does not. Neither can tell you anything about the prose line mentioning AKIA, because that line contains no 20-character key, which is the correct outcome and worth confirming rather than assuming.
Now try the other half of an AWS credential pair, and watch the approach fall apart:
# A secret access key is 40 characters of base64 alphabet. As a regex
# that is nearly content-free: it matches most git hashes, most base64
# blobs, and a good fraction of minified JavaScript.
grep -oE '\b[A-Za-z0-9/+=]{40}\b' secrets.txtThis is the boundary of what regex can do, and knowing where that boundary is matters more than another hour of syntax. Real secret scanners pair a regex with something else: Shannon entropy over the candidate, a proximity requirement (the string must sit near aws_secret_access_key), and increasingly a live validity check against the provider. Regex finds candidates. It does not decide.
6. Lookaround, and living without it
# Lookahead: match a key ID only when NOT followed by more alphanumerics.
# Lookbehind: and not preceded by any either. PCRE / Python / Java only.
python3 -c "
import re
print(re.findall(r'(?<![A-Z0-9])(AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])',
open('secrets.txt').read()))"
# ripgrep defaults to the Rust regex engine, which has no lookaround.
rg '(?<![A-Z0-9])AKIA[A-Z0-9]{16}' secrets.txt # error: look-around not supported
rg -P '(?<![A-Z0-9])AKIA[A-Z0-9]{16}' secrets.txt # -P switches to PCRE2When the engine has no lookaround, express the boundary instead of asserting it. \b covers most cases. Where it does not, match the surrounding character and capture only the part you want:
# Instead of (?<=key = )\S+ which needs lookbehind, # match the label too and take group 1. rg -o 'key\s*=\s*(\S+)' -r '$1' secrets.txt # -r rewrites output to the capture
7. Catastrophic backtracking
A backtracking engine can be made to explore an exponential number of paths. The trigger is nested quantifiers over overlapping alternatives, followed by something that fails.
python3 - <<'PY'
import re, time
evil = re.compile(r'^(a+)+$') # nested quantifier, both greedy
for n in (18, 20, 22, 24):
s = "a" * n + "!" # the "!" guarantees the match fails
t = time.perf_counter()
evil.search(s)
print(n, f"{time.perf_counter() - t:.3f}s")
PYEach extra character roughly doubles the time. At 24 characters you are already waiting; at 40 the process is effectively hung. An attacker who controls the input to a validator, a log parser, or a WAF rule gets a CPU exhaustion primitive for free, and it is one of the few denial-of-service bugs that survives autoscaling, because every worker is equally stuck.
Three defences, in order of preference: use a finite-automaton engine for anything touching untrusted input; avoid nesting a quantifier inside a quantified group when the inner alternatives can match the same text; and where neither is possible, impose a length cap on the input before the regex sees it.
Hands-on exercises
An internal service fetches a URL supplied by the user, and validates it with this allowlist. Give three distinct URLs that pass validation but reach a host you do not control.
re.search(r'https://.*\.internal\.example\.com', url)
Show the answer
https://evil.test/?x=.internal.example.com- no anchors, so the required text can appear anywhere, including in a query string.https://api.internal.example.com.evil.test/- no end anchor, so the allowed suffix is only a prefix of the real host.https://evil.test/#https://api.internal.example.com- same unanchored problem, this time in the fragment.
A fourth exists if the code uses re.search with a ^ added but no $: https://a.internal.example.com@evil.test/ abuses userinfo in the authority. That one is worth knowing because it defeats a pattern that looks properly anchored.
The real lesson is the last one: a URL is a structured value and a regex sees a flat string. Parse it with a URL parser, then compare the parsed host with == or a suffix check on a real hostname. Reach for regex only after the structure is already recovered.
Write one pattern that finds AWS access key IDs in a file, matching both long-term and temporary keys, rejecting keys embedded in longer tokens, and running on ripgrep's default engine (so no lookaround). Then explain why [A-Z0-9]{16} and not \w{16}.
Show the answer
rg -o '\b(AKIA|ASIA)[A-Z0-9]{16}\b' secrets.txt\w is [A-Za-z0-9_]: it admits lowercase and underscore, neither of which appears in an access key ID. Widening a character class beyond the real alphabet costs you precision in exactly the place a scanner can least afford it, because every false positive is a human being asked to rotate a credential that does not exist. Match the actual alphabet, not the convenient shorthand.
Worth noting that \b before A only asserts a boundary; if the preceding character is a word character there is no boundary and the match is correctly rejected, which is why this works without lookbehind.
Which of these is a catastrophic-backtracking risk on a PCRE engine, and which is safe? Say why for each.
A: ^(\w+\s?)*$
B: ^\w+(\s\w+)*$
C: ^(a|ab)+c$
D: ^[a-z]{1,64}$Show the answer
A is dangerous. \s? is optional, so (\w+\s?)* can split a run of word characters between the inner and outer quantifiers in exponentially many ways. Feed it 30 word characters and a ! and it hangs.
C is dangerous for the same structural reason: a and ab overlap, so a long run of a and b that ultimately fails to reach c can be partitioned many ways.
B is safe. The inner group must consume at least one whitespace character before each subsequent word, so there is exactly one way to partition any given input. This is the standard rewrite for A, and it is worth internalising: make each repetition start with something that cannot be produced by the previous one.
D is safe and also shows the cheapest mitigation there is. A bounded quantifier caps the work regardless of engine.
Confirm rather than trust the reasoning. The timing loop from step 7 works on all four, and a pattern that stays flat as n grows is genuinely safe in a way that reading it cannot prove.
Using sample.log, write a one-liner that prints only principals with more than one denied action, and the count. Then say what your pattern would do if a username legitimately contained an = sign.
Show the answer
grep -E 'result=denied' sample.log \ | grep -oE '\buser=[^ ]+' \ | sort | uniq -c | sort -rn \ | awk '$1 > 1' # keep only counts above one
[^ ]+ is greedy up to the next space, so a username containing = is captured correctly, but a username containing a space would be truncated and would silently split into two apparent principals. Delimiter-based extraction always inherits the delimiter's ambiguity.
That is the argument for parsing structured logs as structure. CloudTrail is JSON; running a regex over the raw JSON text to pull out userIdentity.arn works right up until a field contains a quote or a brace, at which point it fails in a way no test caught. Use jq for JSON and save regex for the places where the data really is flat text.
Common mistakes
- An unanchored pattern used as a validator. "Contains" is not "is". Anchor both ends, and in Python use
\Aand\Zrather than^and$. - An unescaped dot inside a hostname.
\.is a literal dot; a bare.is any character. In a domain allowlist that difference is the entire control. - Assuming the engine supports lookaround. Go, Rust, ripgrep's default mode, and anything built on RE2 do not. The pattern does not degrade, it fails to compile, which is at least loud. The quieter version is a colleague deleting the lookaround to make it compile.
- Testing only strings that should match. Same failure as testing only the deny direction of a policy: a pattern that matches everything passes every positive test.
- Case sensitivity taken for granted. Hostnames are case-insensitive, HTTP header names are case-insensitive, and Windows paths are case-insensitive. A detection anchored to lowercase misses the trivially obvious evasion.
- Regex over structured data. JSON, XML, and URLs have parsers. A regex over their serialised form is correct until the first quote, escape, or nested brace.
- Forgetting
.excludes newline. A multi-line payload slips past a single-line pattern, and turning on the dot-matches-newline flag to fix it often makes a greedy quantifier swallow the rest of the file.
Where next
- Sigma and YARA put these patterns to work in detection rules, where the two failure directions become false positives and missed detections.
- jq and JMESPath for the structured-data half of the job, which is most of it in cloud.
- Detection engineering for how a pattern becomes a tested, versioned rule.
- Data security for where credential and PII scanning fits in a wider programme.
- regex101 explains a pattern token by token and shows a step count, which makes backtracking visible. Set the flavour to match your target engine, or the explanation is confidently about a different language.
