REDOS AND WEAK RANDOMNESS
Three bugs from picking the wrong building block. ReDoS is a regex that backtracks for a hundred years. Weak randomness is Math.random() where you needed crypto.randomUUID(). Dictionary-cracked JWT secrets are 'mysecret123' where you needed 256 bits.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.
Three small wrong choices
The three bugs in this article are all the same shape: a developer needed a primitive, the AI suggested one, the suggestion was technically wrong in a way that wasn’t obvious in dev. Months later something breaks. The fix in each case is “use the right primitive.” The catch is that the wrong primitive is shorter, simpler, and the “right” version is sometimes not even installed by default.
ReDoS
const emailRegex = /^([a-zA-Z0-9_\-\.]+)+@([a-zA-Z0-9_\-\.]+)+\.([a-zA-Z]{2,5})$/
if (!emailRegex.test(input)) return res.status(400).send('Invalid email')
Looks fine. Validates emails. Tested against foo@bar.com and valid@example.org. Passed.
Now apply it to aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!. The (...)+ group can split the input in 2^N ways before failing, and the engine tries every one. CPU runs hot for tens of seconds on a 50-character string. A few requests of these takes the server down.
Live: https://gapbench.vibe-eval.com/site/redos/.
The fix is to use a regex that doesn’t have nested quantifiers, or to use a regex engine that runs in linear time (RE2 in Go, the upcoming RE2-compatible options in Node), or to use a parser instead of a regex for things like email validation. Modern recommendation for email: don’t validate emails with regex; send a confirmation email and trust whatever the user typed.
Weak randomness
function generateResetToken() {
return Math.random().toString(36).substring(2, 18) // 16 chars
}
Math.random() is fine for picking the next color in a UI animation. It’s not fine for security tokens. The output is predictable from the seed; modern V8 and Spidermonkey keep the seed reasonably opaque, but with enough output samples and modest computational effort, the seed can be recovered and future tokens predicted.
The fix is one line:
import { randomUUID } from 'crypto'
function generateResetToken() {
return randomUUID()
}
Or crypto.randomBytes(32).toString('hex') for 256 bits of entropy in hex form.
The problem is that AI generators have seen a lot of code with Math.random() for this purpose — it’s the shorter pattern, it’s in old tutorials, it works in dev. The unsafe choice is the path of least resistance.
Live: https://gapbench.vibe-eval.com/site/weak-randomness/.
Weak JWT secret
const SECRET = 'mysupersecretkey' // ¯\_(ツ)_/¯
const token = jwt.sign({ userId }, SECRET)
'mysupersecretkey' is 16 characters. About 96 bits of entropy if it were random. Realistically? It’s English-shaped, so the attacker tries a dictionary first and finds it in seconds.
Once the attacker has the secret, they sign their own JWTs with arbitrary claims. The server believes them because the signature verifies. Now they’re whoever they want to be.
Tools: hashcat -m 16500 runs JWT cracking. Against a JWT with HS256 and a weak password, modern GPUs go through tens of millions of dictionary candidates per second.
The fix:
- Generate a 256-bit random secret.
openssl rand -hex 32produces one. - Store it in a secret manager, not in source. Rotate quarterly.
- Better: use RS256 (asymmetric) so the signing key never has to be on the verifier. The verifier holds a public key; the signer holds a private key. No shared secret to crack.
Live: https://gapbench.vibe-eval.com/site/weak-jwt-secret/.
A specific incident — the regex that took down the service
Anonymized. The product was a webhook-receiving SaaS — third-party services sent webhooks to customer-defined URLs. The webhook routing layer parsed the URL with a regex. The regex was the kind of email-validation-but-for-URLs pattern AI codegen produces from memory. It had nested quantifiers.
A customer (probably an attacker testing the limits) configured a webhook URL with a long sequence of characters that triggered catastrophic backtracking. The regex spent ~30 seconds on the input. Each request held a Node event loop slot for those 30 seconds. The customer fired ~50 such requests in parallel.
Total downtime: ~25 minutes. Recovery required scaling out the service horizontally to soak the parallel-stuck requests, while the team identified the regex and replaced it. The regex was three lines of pattern-matching code; the replacement was a proper URL parser.
The bug class is called ReDoS — Regular Expression Denial of Service. It’s pure CPU exhaustion, no data leak, but it takes services down at scale. AI codegen produces it because:
- The model learned a lot of regex patterns from low-quality tutorials.
- The model doesn’t know which regex engines are vulnerable to backtracking (most are).
- The “validate this format” prompt almost always gets a regex back, even when a parser is the right answer.
A taxonomy of catastrophic patterns
The regex shapes that explode:
- Nested quantifiers:
(a+)+,(a*)*,(a|aa)+. The engine tries multiple ways to split the input and fails on each. - Overlapping alternations:
(a|a)*. Each character can be matched by either branch; engine tries both. - Greedy with backtracking:
.*$followed by something that sometimes matches. Engine consumes everything, then backtracks character by character. - Email regex from 2010 tutorials:
^[\w.-]+@[\w.-]+\.[\w]{2,}$— permissive, prone to specific input crashes.
Modern advice is “don’t validate emails with regex.” Send a confirmation email; trust the bounce. For URLs, use a real URL parser (new URL() in JS, urllib.parse in Python). For phone numbers, use a library like libphonenumber. The regex is rarely the right answer for any structured format.
Math.random vs crypto — why it matters
Math.random() is a Mersenne Twister or similar PRNG, seeded once at process start. Output is deterministic given the seed. With enough output samples — say, 500-2000 consecutive outputs — an attacker can recover the seed and predict all future outputs.
This used to be theoretical. It isn’t anymore: tooling exists (v8-prng-cracker, similar) that does this in seconds. If you generate password reset tokens with Math.random, an attacker who has 1000 of your tokens (from triggering 1000 password resets — many apps don’t rate-limit the reset request, only the reset use) can predict the next 1000.
The fix is crypto.randomUUID() or crypto.randomBytes(). Both are CSPRNG-backed. The output is unpredictable regardless of how many samples an attacker has.
A taxonomy of where weak randomness ends up
In addition to password reset tokens:
- Session IDs. If your session middleware uses Math.random for the session ID, attackers can predict valid sessions.
- CSRF tokens. Same.
- OAuth state parameters. Predictable state fails the CSRF check that state was supposed to provide.
- Nonces in cryptographic operations. Critical — predictable nonces in some schemes (CTR mode, ECDSA) leak the key.
- Magic-link verification codes. Predictable codes mean any user’s account can be claimed.
- Coupon codes. Predictable codes can be enumerated.
Any of these on Math.random is a finding.
Wrong fix vs right fix — JWT secrets
// WRONG: secret in code, low entropy
const SECRET = 'mysecretkey123' // dictionary-crackable in seconds
// WRONG: secret from env but not actually random
process.env.JWT_SECRET = 'production-secret-2026' // still dictionary-crackable
// RIGHT: 256-bit random secret from a proper source, rotated quarterly
// Generated with: openssl rand -hex 32
const SECRET = process.env.JWT_SECRET // 64-character hex string
if (!SECRET || SECRET.length < 64) throw new Error('JWT_SECRET must be 256 bits')
// BETTER: RS256 with key pair, signing key in HSM or KMS, only public on verifier
import { createPublicKey } from 'crypto'
const publicKey = createPublicKey(process.env.JWT_PUBLIC_KEY)
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
// No shared secret to crack offline
Cross-stack notes
- JavaScript:
crypto.randomUUID()is the modern simple answer.crypto.randomBytes(N)for raw bytes. - Python:
secrets.token_hex(32)is the equivalent.random.random()is unsafe;secretsis the safe alternative. - Java:
SecureRandomnotRandom. The naming difference is the entire safety story. - Go:
crypto/randnotmath/rand. AI-generated Go code usesmath/randregularly because the import path is shorter. - Rust:
rand::rngs::OsRngfor cryptographic use,rand::thread_rng()for non-cryptographic.
How we detect
ReDoS: we identify endpoints that take string inputs and apply regex validation. We submit a battery of known catastrophic-backtracking payloads and measure response time. Anything that produces a multi-second response from a small input is a finding.
Weak randomness: we collect a sample of generated tokens (typically by triggering the token-issuing endpoint many times) and analyze their distribution. If they appear to be Math.random()-shaped — short, base-36 alphabet, with statistical patterns — we flag.
Weak JWT secret: we collect a JWT, attempt offline crack with a small dictionary (10,000 common secrets), and report any match.
The detections are all runtime. Static scanners can flag the unsafe primitive in source but can’t confirm the secret strength or distribution properties.
CWE / OWASP
- CWE-1333 — Inefficient Regular Expression Complexity
- CWE-330 — Use of Insufficiently Random Values
- CWE-326 — Inadequate Encryption Strength
- OWASP Top 10 — A02:2021 Cryptographic Failures, A04:2021 Insecure Design
Reproduce it yourself
- ReDoS: https://gapbench.vibe-eval.com/site/redos/
- Weak randomness: https://gapbench.vibe-eval.com/site/weak-randomness/
- Weak JWT secret: https://gapbench.vibe-eval.com/site/weak-jwt-secret/
Related reading
- Pattern: JWT alg=none is not dead
- Pattern: Magic links, OTP, and password resets
- Tool: vibe-code-scanner
COMMON QUESTIONS
AUDIT YOUR PRIMITIVES
We probe regex inputs that exhibit catastrophic backtracking, token entropy in your auth flow, and JWT secret strength.