# How I Use Claude Code to Fix Security Vulnerabilities

## About this article

This article documents how I use Claude Code in my CVE research workflow — specifically, how I handle the moment Claude gives me a fix recommendation for a vulnerability I've found through static analysis.

Over many sessions I noticed a recurring pattern: when I ask Claude to patch a vuln, it often returns **2–3 options instead of one answer**. At first this looked like a weakness — I just wanted "the fix." After enough real cases, I realized it's actually a useful behavior most of the time (real trade-offs exist in security work), and a footgun the rest of the time (Claude hedging because my prompt was vague).

What follows is the framework I now use to work with Claude on security fixes:

*   **§1–§2** — Why multiple-option answers happen and the 5 trade-off shapes I see most often
    
*   **§3** — A 5-axis evaluation framework for picking the right option
    
*   **§4–§5** — How to tell genuine trade-offs from Claude hedging
    
*   **§6** — The step-by-step workflow I run when Claude gives me options
    
*   **§7** — How I write prompts that prevent hedging in the first place
    

If you're using AI to help with security fixes — whether for CVE research, internal pentest reports, or just routine vulnerability triage — this should save you some of the trial and error it took me to figure out.

* * *

## 1\. The reality: most security fixes don't have "one right answer"

Unlike ordinary bug fixes (broken logic → patch it), security fixes usually have **multiple valid approaches** because:

*   Each fix lives at a **different layer** of the pipeline
    
*   Each fix has **different trade-offs** (security strength vs UX vs compatibility vs performance)
    
*   Claude **doesn't know** what the project prioritizes → it offers options so you can decide
    

This isn't Claude being lazy or unsure — it's Claude **respecting your domain knowledge** about the project's context.

* * *

## 2\. Common trade-offs when Claude offers 2 options

### A. Minimal fix vs Defense in depth

**Example — Stored XSS in a Twig template:**

```php
// Vulnerable
{{ user.bio | raw }}
```

**Option A — Minimal (fix at the sink):**

```php
{{ user.bio }}  // remove |raw, let Twig auto-escape
```

*   Smallest diff, easiest to review
    
*   No change to data structure
    
*   If another template also renders `bio` → still vulnerable
    

**Option B — Defense in depth (sanitize at input + escape at output):**

```php
// In the controller (input)
$user->bio = strip_tags($request->bio, '<b><i><a>');

// In the template (output)
{{ user.bio | raw }}  // data is now clean
```

*   Protection at 2 layers — safe even if `|raw` is accidentally added elsewhere
    
*   Larger diff, touches input validation
    
*   If `strip_tags` strips too aggressively → user loses data
    

**Claude offers 2 options because:**

*   Security-conscious maintainers usually prefer **B** (defense in depth)
    
*   Maintainers who prioritize a small, easy-to-review PR prefer **A**
    
*   Claude doesn't know the project's culture
    

* * *

### B. Library/framework approach vs Manual approach

**Example — SQL Injection in a raw PHP query:**

```php
// Vulnerable
$db->query("SELECT * FROM users WHERE email = '$email'");
```

**Option A — Use the framework's ORM/Query Builder:**

```php
// Laravel Eloquent
User::where('email', $email)->first();
```

*   Idiomatic for a Laravel codebase
    
*   Auto-parameterized
    
*   If raw queries are used elsewhere → inconsistent
    

**Option B — Plain PDO prepared statement:**

```php
$stmt = $db->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
```

*   Minimal change — still raw SQL, just adds binding
    
*   No ORM knowledge required
    
*   If the project already uses ORM → goes against the established pattern
    

**Claude offers 2 options because** it can't tell the project's **idiomatic style** — reading 1-2 files isn't enough to be sure.

* * *

### C. Strict vs Permissive validation

**Example — SSRF in a webhook URL:**

```php
// Vulnerable
$response = file_get_contents($request->webhook_url);
```

**Option A — Allowlist (strict):**

```php
$allowed_hosts = ['api.partner-a.com', 'webhook.partner-b.com'];
$host = parse_url($url, PHP_URL_HOST);
if (!in_array($host, $allowed_hosts)) {
    throw new Exception('Host not allowed');
}
```

*   Most secure — only pre-approved hosts allowed
    
*   Kills the "arbitrary webhook" feature — poor UX
    

**Option B — Blocklist (block private IP ranges):**

```php
$ip = gethostbyname(parse_url($url, PHP_URL_HOST));
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
    throw new Exception('Private IP not allowed');
}
```

*   Keeps arbitrary-webhook functionality
    
*   Blocks 99% of common SSRF attacks
    
*   DNS rebinding can still bypass if you don't re-resolve
    

**Claude offers 2 options because** this is a **product decision**, not a pure technical decision. "Webhook" means different things for different products.

* * *

### D. Reject vs Sanitize vs Encode

**Example — XSS in a comment system:**

**Option A — Reject (block input):**

```php
if (preg_match('/<script|on\w+=/i', $comment)) {
    throw new Exception('Invalid input');
}
```

**Option B — Sanitize (clean input):**

```php
$comment = HTMLPurifier::clean($comment);  // strip dangerous tags, keep safe HTML
```

**Option C — Encode (output safely):**

```php
echo htmlspecialchars($comment);  // keep raw input, escape when rendering
```

**Trade-off:**

|  | Option A | Option B | Option C |
| --- | --- | --- | --- |
| **UX** | Poor (false rejects) | Medium | Best |
| **Security** | Weak (regex bypass) | Strong | Strongest |
| **Performance** | Fast | Slow (HTMLPurifier is heavy) | Fast |
| **Implementation cost** | Low | High (new dependency) | Low |

This is a **classic security architecture decision** — there's no right or wrong, only what fits the context.

* * *

### E. Breaking change vs Backward compatible

**Example — JWT auth bypass via** `alg: none`**:**

```python
# Vulnerable
jwt.decode(token, key, algorithms=['HS256', 'RS256', 'none'])
```

**Option A — Breaking (accept only one algorithm):**

```python
jwt.decode(token, key, algorithms=['RS256'])  # drops 'none' and HS256
```

*   Most secure
    
*   Existing HS256 tokens stored in clients will fail → mass re-login required
    

**Option B — Non-breaking (keep HS256, drop 'none'):**

```python
jwt.decode(token, key, algorithms=['HS256', 'RS256'])
```

*   Fixes the vuln, breaks nothing
    
*   HS256 still carries risk if the secret is leaked
    

**Claude offers 2 options because** it doesn't know:

*   How many users hold HS256 tokens
    
*   Whether mass re-login is acceptable
    
*   Whether a key rotation mechanism exists
    

* * *

## 3\. How to read Claude's options — an evaluation framework

When Claude offers 2-3 options, evaluate along **5 axes**:

1.  **Security strength** — Which option closes the hole more tightly?
    
2.  **Diff size** — Which is easier to review?
    
3.  **Compatibility risk** — Which might break users/clients?
    
4.  **Idiomatic with the project** — Which matches the codebase's style?
    
5.  **Maintainability** — Will a new developer understand this 6 months from now?
    

**Rules of thumb for responsible disclosure PRs:**

| Situation | Pick |
| --- | --- |
| First PR to a large project | **Minimal fix** — easier to accept; you can follow up with a broader PR |
| Critical vuln, exploit already in the wild | **Defense in depth** — close as many layers as possible |
| Project with a clear style guide | **Idiomatic option** — even if the diff is larger |
| Library/SDK with many downstream consumers | **Non-breaking option** — avoid forcing mass migration |

* * *

## 4\. When Claude SHOULDN'T offer 2 options (an anti-pattern)

Sometimes Claude offers 2 options but only **one is actually correct** — this is a sign Claude is **hedging** due to lack of confidence:

> **Option A: use** `mysqli_real_escape_string`
> 
> **Option B: use prepared statements**

This isn't a real trade-off — prepared statements are **always better**. Claude offered A out of caution. **You should push Claude to pick one and explain.**

> **Option A: hash passwords with MD5**
> 
> **Option B: hash passwords with bcrypt**

MD5 for passwords is **never acceptable**. This isn't a choice.

**When you suspect Claude is hedging:**

*   Ask: "If you had to pick one, which would you choose and why?"
    
*   Ask: "Is there industry consensus on this?"
    
*   Check the OWASP cheat sheet — if there's a clear recommendation → follow it
    

* * *

## 5\. When offering 2 options actually has value

**It has value when:**

*   The 2 options sit at **different layers** (input vs output, framework vs manual)
    
*   The 2 options carry **obvious trade-offs** that anyone can see
    
*   The 2 options require **domain knowledge you have but Claude doesn't** (business logic, user base, deployment context)
    
*   The project maintainer has a **strong opinion** that Claude isn't aware of
    

**It has no value when:**

*   The 2 options differ only in syntax
    
*   One of the 2 options is **clearly less secure**
    
*   Claude is offering them just to hedge against uncertainty
    

* * *

## 6\. A workflow for handling 2-option recommendations

```plaintext
Step 1: Read the trade-off Claude presents
"Option A: minimal, Option B: defense in depth"
→ Is this trade-off real?

Step 2: Map it onto the project's context
- Does the project have a strong security culture? (read SECURITY.md, CHANGELOG)
- Does it have a style guide? (read CONTRIBUTING.md)
- What kind of PRs does the maintainer typically accept? (read recent merged PRs)

Step 3: Ask Claude again, this time with context
"This project uses idiomatic Laravel with Sanctum for auth. Given that, which option do you pick?"

Step 4: Decide
- First PR: go minimal
- Already discussed with the maintainer: go with what they want

Step 5: Document the choice in the PR description
"Considered Option B (defense in depth) but chose A for minimal diff. Happy to extend if maintainer prefers."
→ Shows you thought about it — not a copy-paste

```

* * *

## 7\. Writing better prompts to reduce hedging in the first place

Section 4 covered how to spot hedging *after* it appears. Better: prevent it. Most hedging traces to three gaps — no constraints, no project context, or you explicitly asked for options.

### 7.1 Prompts that work

**Force a decision with constraints:**

> ❌ "How should I fix this XSS?"
> 
> ✅ "Fix this XSS with the smallest diff. First-time PR to a project I don't maintain. Pick one and apply it."

**Bake context into the prompt:**

> ❌ "Fix the SSRF in `webhook.php`."
> 
> ✅ "Fix the SSRF in `webhook.php`. B2B integration platform — allowlist won't work. Block private IPs with the right PHP API. Apply the fix."

**Ask for a recommendation, not options:**

> ❌ "What are my options for fixing this JWT bypass?"
> 
> ✅ "Recommend the single best fix. Mention the trade-off you rejected in one sentence."

**Set a confidence threshold:**

> ✅ "Pick one fix and apply it. Mention an alternative only if it's 30% better on security, maintainability, or performance."

### 7.2 A reusable template

```plaintext
Fix [vulnerability class] in [file].
Context: [framework, scale, audience — 1 sentence].
Constraints: [smallest diff / defense in depth / breaking-change OK?].
Decision: pick one fix and apply it. Alternatives only if substantially better.
```

### 7.3 Stop phrases that invite hedging

| Stop phrase | Use instead |
| --- | --- |
| "What are my options" | "Recommend one" |
| "How could I fix this" | "Apply the best fix" |
| "What do you think?" | "Pick one and explain" |
| "Compare X and Y" | "Which is right here, and why?" |

### 7.4 When you DO want options

Sometimes the trade-off is a real product decision (e.g., Section 2.C). Ask explicitly and bound the answer:

> ✅ "Give me exactly two fixes: one allowlist, one blocklist. For each: security strength, UX impact, one failure mode. No third option."

* * *

## 8\. What this workflow has produced

### Published advisories

*   [**GHSA-6jq6-x4cx-qvcm**](%5Bhttps://github.com/firefly-iii/firefly-iii/security/advisories/GHSA-6jq6-x4cx-qvcm%5D) — in `[Firefly III]`
    

### Pull Request

*   [**SQL Injection**](%5Bhttps://github.com/Cacti/cacti/pull/7146%5D) — in `[Cacti]`
    
*   [**Cross-Site Scripting (XSS) stored**](%5Bhttps://github.com/Cacti/cacti/pull/7147%5D) — in `[Cacti]`
    
*   [**Cross-Site Scripting (XSS) stored**](%5Bhttps://github.com/akaunting/akaunting/pull/3363%5D) — in `[Akaunting]`
    
*   [**Cross-Site Scripting (XSS) stored**](%5Bhttps://github.com/bagisto/bagisto/pull/11307%5D) — in `[Bagisto]`
    

* * *

## TL;DR

*   Claude offers more than one options because **security fixes have real trade-offs**
    
*   5 common trade-off categories: minimal vs defense-in-depth, library vs manual, strict vs permissive, reject vs sanitize vs encode, breaking vs backward-compat
    
*   Evaluate options along 5 axes: security, diff size, compatibility, idiomatic fit, maintainability
    
*   Watch for **hedging** — when Claude offers 2 options but one is clearly wrong, push it to pick one
    
*   The best prevention is upstream — **write prompts that constrain and decide** (see §7)
    
*   For a first disclosure PR: **default to the minimal fix** — easier to accept, less likely to spark debate
    
*   Always **document in the PR** why you picked that option — maintainers respect a reasoned choice
