Question #7 on this list is JWTs. In a 700-function benchmark I ran across 5 AI
models,
Claude Opus generated vulnerable JWT code 7 out of 7 times — leaking
sensitive data into the token payload on every single run. A candidate who can
recite the algorithm: none bypass cold will still merge that diff, because the
model wrote it and the diff looked clean. Reciting the answer and not shipping
the bug are two different skills, and interviews only test the first one.
I've interviewed 50+ backend and full-stack engineers, and the pattern is
relentless: the candidates who define these vulnerabilities flawlessly are the
same people whose PRs I later flag for the exact bug they just defined. Security
questions show up in almost every loop now — even for roles that aren't labeled
"security" — and they all test recall. None of them test the thing that
actually ships the bug: what your editor autocompletes at 5pm on a Friday.
It's gotten worse with AI in the loop. Ask Copilot or Claude to "query the user
by id" and you'll often get string-interpolated SQL — the same CWE-89 the
candidate aced an hour earlier. The model has read every Stack Overflow answer,
including the wrong ones, and it has no opinion about which it pastes.
So here are the 13 questions that come up the most — each with the answer in
one breath, the bad-vs-good code, the CWE, and the part most cheat-sheets skip:
the ESLint rule that fails CI when you (or your assistant) ship the bad
version anyway.
The best answer to "how do you stay current?" isn't "I read CVEs." It's "I
encode the answer to every one of these into a rule, so neither I nor an AI can
merge the wrong version." This is how.
If you'd rather run the list against your own codebase than read it as trivia,
it's one install — full config and the per-surface matrix are below:
npm install --save-dev eslint-plugin-secure-coding eslint-plugin-node-security \
eslint-plugin-jwt eslint-plugin-pg eslint-plugin-browser-security eslint-plugin-express-security
The fundamentals (asked ~90% of the time)
1. SQL Injection
db.query(`SELECT * FROM users WHERE id = ${userId}`); // ❌
db.query("SELECT * FROM users WHERE id = $1", [userId]); // ✅
Say: "Parameterized queries separate data from code." CWE-89. Enforced by
pg/no-unsafe-query.
This is the one I most often watch get re-introduced. A candidate explains it
perfectly, then a week later asks an assistant to "add a search filter," gets a
template literal back, and merges it because the diff looked small. The rule
catches the interpolation regardless of who typed it — there's a whole
breakdown of the three shapes it has to detect in
Three SQL Injection Patterns in node-postgres.
2. XSS (and its three types)
Stored (saved to the DB), reflected (echoed from the URL), and DOM (written by
client JS). The browser-side fix is the same reflex:
element.innerHTML = userInput; // ❌
element.textContent = userInput; // ✅
CWE-79. Enforced by browser-security/no-innerhtml.
Why it survives review: the innerHTML almost always started life rendering a
trusted string — a hard-coded template, an icon, a bit of formatted markup. It
passed review because at the time the input genuinely was safe. Then a feature
landed that routed user content through the same helper, and the assignment never
changed. The diff that introduces the vulnerability touches the caller, not the
innerHTML line — so the reviewer looking at the dangerous line sees code that's
been there for a year. The rule flags the sink regardless of when the taint
arrived.
3. Password storage
crypto.createHash("md5").update(password); // ❌
await bcrypt.hash(password, 12); // ✅
Say: "bcrypt or argon2, per-user salt, a deliberate work factor." Two CWEs
live here: a fast general-purpose hash (MD5/SHA-1) is CWE-327 (broken
algorithm), and even a "real" hash with too low a work factor is CWE-916
(insufficient computational effort). The first is the one a linter can see
syntactically — node-security/no-weak-hash-algorithm flags the md5/sha1
call directly.
Why it survives review: the createHash("md5") line usually predates the
auth rewrite. It started life hashing an ETag or a cache key — a perfectly fine
use of MD5 — and someone later reached for "the hash function we already have"
when they added password storage. The reviewer sees a familiar helper, not a
credential path. The rule doesn't care about intent; it flags every weak-hash
call site and lets you allow-list the genuinely benign ones.
Intermediate (asked ~70% of the time)
4. CSRF
Cross-Site Request Forgery rides an authenticated user's cookies to perform
actions they didn't intend. Prevention: synchronizer tokens, SameSite
cookies, origin checks. CWE-352. Enforced by
express-security/require-csrf-protection.
Why it survives review: the app started as a JSON API where CSRF "doesn't
apply" — no cookies, bearer tokens only. Then one team added a cookie session
for the admin panel, and every state-changing route inherited the cookie auth
without anyone re-opening the CSRF question. Nobody reviews "did this still-stateless-looking
endpoint just become CSRF-able?" because the route handler didn't change — the
auth middleware around it did. The rule flags the unprotected mutating route, not
the day the cookie showed up.
5. The Same-Origin Policy
Browsers isolate by origin (scheme + host + port). The controlled relaxations are
CORS, postMessage, and (legacy) JSONP — and an over-broad CORS policy
re-opens everything (CWE-942). browser-security/no-permissive-cors catches
the wildcard.
6. Timing attacks
if (userToken === secretToken) {
} // ❌ leaks via comparison time
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); // ✅ constant-time
CWE-208. Enforced by node-security/no-timing-unsafe-compare.
Why it survives review: === on two secrets looks more correct than the
timingSafeEqual alternative, not less — it's the obvious, readable comparison,
and the test suite passes identically either way because timing leaks don't show
up in unit tests. A reviewer has no red flag to react to; the bug is in how long
the line takes, not in what it returns. The rule knows the operands are secrets
and flags the variable-time compare the eye reads as fine.
7. JWTs
Verify the signature, check exp, never accept algorithm: "none", and store in
an httpOnly cookie — not localStorage. CWE-347 for the none bypass
(jwt/no-algorithm-none); the storage mistake is browser-security/no-jwt-in-storage.
The none bypass is worth seeing in full — it's a one-line auth bypass that
most verify-then-trust code waves through.
Advanced (asked ~50% of the time)
8. Prototype pollution
obj[key] = value; // ❌ key="__proto__" pollutes Object.prototype
if (key !== "__proto__" && key !== "constructor" && key !== "prototype")
obj[key] = value; // ✅
CWE-1321. Caught by secure-coding/detect-object-injection (the rule's own
finding tags CWE-915; CWE-1321 is the canonical JS-prototype-pollution entry).
Why it survives review: the dynamic assignment lives inside an innocuous
helper — a config merger, a query-string parser, a deepMerge someone copied
off Stack Overflow — and it was written and reviewed against trusted input. The
vulnerability only appears when a later caller pipes a request body through that
helper, and that diff touches the caller, not the obj[key] = value line. The
reviewer sees a merge utility that's been stable for a year. The rule flags the
unguarded computed write itself, no matter how trusted the original caller was.
9. Content Security Policy
An HTTP header that restricts what can load: default-src 'self'; script-src 'self' 'nonce-…'.
A missing CSP is a CWE-693 protection-mechanism failure; the absence-of-header
finding from browser-security/require-csp-headers tags CWE-1021 (the
UI-redress / clickjacking surface CSP's frame-ancestors closes). The companion
no-unsafe-inline-csp flags the 'unsafe-inline' that defeats the policy you did
ship.
10. ReDoS
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaa!"); // ❌ catastrophic backtracking
Regular-Expression Denial of Service. Caught by
secure-coding/no-redos-vulnerable-regex.
Architecture (asked ~40% of the time)
11. Designing secure authentication
- Password hashing (bcrypt/argon2)
- Rate limiting on login + account lockout after repeated failures
- MFA
- Secure session management
- Password reset via time-limited tokens
Most of this is architectural, but the enforceable slice is real: the missing
rate limiter is express-security/require-rate-limiting. On a login route that
gap is CWE-307 (excessive authentication attempts — i.e. brute force); the
rule's general finding is the broader CWE-770 (no limit on resource
allocation), which is the same defect seen from the resource side. It survives
review because rate limiting is "ops' job" in everyone's mental model until the
credential-stuffing run shows up in the logs.
12. Secrets management
Environment variables at minimum; a secrets manager (Vault, AWS Secrets Manager)
in production; nothing in code or git history; a rotation policy. CWE-798 —
secure-coding/no-hardcoded-credentials is the backstop for the last one. Entropy
scoring doesn't cut it on its own — see
No Hardcoded Credentials: Why Entropy Isn't Enough
for why pattern-only detection misses real secrets, and
Hardcoded Secrets + AI Agents
for the compounding problem when an AI agent is the one committing the leak.
13. Securing a REST API
AuthN (JWT/OAuth2), AuthZ (RBAC/ABAC), input validation, rate limiting, HTTPS
only, and a tight CORS policy — express-security/require-helmet plus the rate
and CORS rules above cover the configuration half.
Quick-reference: the six with a one-line code fix
| Vulnerability | Prevention | CWE | Enforced by |
|---|---|---|---|
| SQL Injection | Parameterized queries | CWE-89 | pg/no-unsafe-query |
| XSS | Output encoding | CWE-79 | browser-security/no-innerhtml |
| Timing attack | timingSafeEqual |
CWE-208 | node-security/no-timing-unsafe-compare |
| Weak password hash | bcrypt / argon2 | CWE-327 | node-security/no-weak-hash-algorithm |
| Prototype poll. | Key allow-list | CWE-915 | secure-coding/detect-object-injection |
| ReDoS | Linear-time regex | CWE-1333 | secure-coding/no-redos-vulnerable-regex |
The test interviews can't run: paste it back to your AI
There is a structural reason AI coding assistants reproduce these exact bugs: they
were trained on the same public corpus that contains both the vulnerable patterns
and the secure rewrites — but the vulnerable versions outnumber the rewrites by a
wide margin (Stack Overflow answers accumulate years of "works for me" before
anyone updates them). The model has no internal security reviewer; it generates the
most statistically likely completion, which is the version most developers shipped.
Across a benchmark of 700 AI-generated functions across 5 models,
every model reproduced at least one of the 13 bug classes on this list — and
choosing a different plugin alone shifts your detection rate significantly.
The rule is the correction signal the model never got during training.
Here's the experiment that reframed this whole list for me. Take the 13 bad-vs-good
snippets above, throw away the good halves, and ask a coding assistant to "fix" or
"refactor" or "add a feature to" the bad ones. A meaningful share come back still
vulnerable — sometimes the model even re-introduces the exact pattern you removed,
because it's optimizing for "looks like working code," not "passes a security
review." I went through this in detail with an 80-function run in
I Let Claude Write 80 Functions — 65-75% Had Security Vulnerabilities
(remediation only landed 50-54% of the time when I fed the findings back), and the
follow-on problem — fixing one finding only to spawn the next — in
The AI Hydra Problem.
And before you assume this is a "bad model" problem you can solve by switching
vendors: it isn't. When I ran the same 20 prompts across Claude and Gemini —
700 functions, 5 models —
every model landed between a 49% and 73% vulnerability rate, and the rankings
inverted by category. Claude Opus wrote insecure JWTs 7/7 (question #7 here);
Gemini 2.5 Flash wrote them perfectly 0/7 — but Flash lost other categories Claude
won. There is no model you can pick that gets this list right by default. The rule
doesn't care which model — or which human — typed the diff; it fails the same CWE
the same way every time.
There's a deeper reason the interview format misses these. Every question above
is phrased as "what's wrong with this code?" — a commission bug, a line that's
present and incorrect. But most of what I flag in AI-generated PRs is the
opposite: an omission. The missing rate limiter on the login route. The absent
httpOnly flag. The CSP header that was never sent. The model didn't write the
wrong thing — it just never wrote the guard, because the prompt never asked for
it. You can't spot an omission by reviewing the diff; there's nothing red to
react to. That's exactly the negative space a rule lives in: it asserts the guard
should be there and fails when it isn't.
The interview tests whether you know the answer. It can't test whether your
editor's autocomplete does — and it definitely can't test for the line nobody
wrote. That's the gap a rule closes: it sits between the generated diff and
main and fails the build on the same CWE the interview asked about — every
time, for every author, human or model.
The "great" answer: enforce it, don't memorize it
Reciting these in an interview proves you know them. Wiring them into CI proves
neither you nor your assistant can ship them. Each concept above maps to a rule in
a domain-specific Interlace plugin — install the
layers your stack uses and the bad version gets flagged (run with
--max-warnings 0 in CI so every finding blocks, not just the error-tier ones):
# npm (yarn/pnpm/bun: same packages, that manager's -D/--dev flag)
npm install --save-dev eslint-plugin-secure-coding eslint-plugin-node-security \
eslint-plugin-jwt eslint-plugin-pg eslint-plugin-browser-security eslint-plugin-express-security
// eslint.config.mjs — `configs` is a NAMED export (default export is the plugin).
// Every plugin follows the identical `configs as X` -> X.recommended pattern,
// so all six layers wire the same way — no per-plugin special-casing.
import { configs as secureCoding } from "eslint-plugin-secure-coding";
import { configs as nodeSecurity } from "eslint-plugin-node-security";
import { configs as jwt } from "eslint-plugin-jwt";
import { configs as pg } from "eslint-plugin-pg";
import { configs as browserSecurity } from "eslint-plugin-browser-security";
import { configs as expressSecurity } from "eslint-plugin-express-security";
export default [
secureCoding.recommended,
nodeSecurity.recommended,
jwt.recommended,
pg.recommended,
browserSecurity.recommended,
expressSecurity.recommended,
];
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | `^8.0.0 \ |
| Module system | CommonJS — {% raw %}eslint.config.js or .mjs
|
| Oxlint | flagship rules wired via the interlace-* ports, CI-gated |
For the full OWASP picture (and the two categories static analysis honestly
can't reach), see
the OWASP Top 10 mapping.
Related reading in this security-on-the-Node-stack series:
- The 30-Minute Security Audit — run these rules against a codebase you've just inherited and triage the findings.
-
Three SQL Injection Patterns in node-postgres — the three shapes
pg/no-unsafe-queryhas to catch (question #1 in depth). -
The JWT
algorithm: noneAttack — question #7 as a one-line, paste-into-your-codebase exploit. - I Let Claude Write 80 Functions — the data behind "your AI re-introduces these."
Links
- 📦 eslint-plugin-secure-coding · node-security · jwt · pg · browser-security
- 📖 Full rule docs (per-rule CWE)
- 💻 Source on GitHub
Which of these 13 is the one your team actually shipped — the textbook bug
everyone could define in the interview and still merged into main? For me it
was the md5 password hash hiding behind an "it was already imported." Tell me
yours in the comments; I'm collecting the failure modes the cheat-sheets never
mention.
⭐ Star on GitHub if you'd rather enforce this list than memorize it for the next interview.
I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack.
Top comments (0)