Skip to content
All posts

Writing More Secure Code with LLMs: Why "Make No Mistakes" Falls Short

Kristov Atlas

Kristov Atlas

@kristovatlas
Published on
· 18 min read

What actually makes an AI write safer code, measured on one build task with validated vulnerability counts. First in a multi-part series from the Monad Foundation engineering team.

Two prompts for the same task and model side by side: a generic be-secure prompt with 4 high-severity bugs versus an app-specific threat-model prompt with about 43 percent fewer findings

The same task and model, two prompts, two different security outcomes.

Our engineering team has two important callings: to create projects that fulfill an unmet niche in the Monad ecosystem, and to encourage solid practices among other Monad builders. Increasingly, we do that work with the help of LLMs. We've noticed that existing research on writing secure code has focused largely on bug detection. To shift attention "left" in the software lifecycle, we've been researching how to guide LLMs to write better code from the start.

Here's a tempting assumption: tell an AI coding assistant to be secure and not make mistakes, and it will write secure code. We tested that. We held the task and the model fixed, wrote three different prompts, turned a panel of independent AI security reviewers loose on each result, and ran a validation pass over every finding they reported. The generic "be secure" checklist barely moved the total and doubled the high-severity bugs. When that generic approach flopped, we wrote an expert prompt that spelled out a real threat model for that specific app, and it cut total findings by about 43%. The task and the model stayed the same; the only thing we changed was the words in the prompt.

The results

We scored each prompt's output against the same task and bucketed the validated findings by severity:

Prompt variantCriticalHighMediumLowInfoTotal
baseline (just the feature spec)02310621
expert (feature spec + a real threat model)0225312
inline-checklist (feature spec + a generic OWASP-style checklist)0418720
Stacked bar chart of validated findings by severity for baseline (21), expert (12, fewest), and inline-checklist (20), with inline-checklist doubling high-severity findings from 2 to 4

Validated findings by severity. "expert" is shortest overall. "inline-checklist" carries the most High-severity findings even though its total is close to baseline.

Each run was a single generation with no fixing afterward. Four independent AI reviewers scanned the result, and a separate step confirmed or threw out every finding. This is a sample size of one: I ran it once per prompt, so treat the counts as a rough signal and the exact numbers as noisy. The limitations section is blunt about how far these go.

Why we ran this

A lot of our first drafts come straight out of AI coding assistants. How secure those drafts are decides how much review a feature needs before it ships, so "what makes the model write safer code" is a question we care about for selfish reasons. We're publishing because the other half of our job is helping teams across the ecosystem ship secure code, and this part is easy for you to implement immediately: it works with whatever model you already use, and it ends with a workflow you can paste into your next feature. None of it is blockchain-specific. If your team writes code with an AI assistant, it's for you.

What we asked the AI to build

The task was a URL unfurler: the thing that turns a pasted link into a preview card in Slack or Discord. A user sends a URL, your server fetches it, parses the returned HTML for the title and OpenGraph tags, and hands back a little JSON card.

Diagram of a URL unfurler in four steps: user posts a URL, the server fetches the stranger-chosen URL over the public internet, receives HTML back, and returns a preview card as JSON

A URL unfurler in four steps. Step 2 is where the security problem lives.

Step 2 is the whole security problem. Your server makes an outbound request to a URL that a stranger picked. That's SSRF, Server-Side Request Forgery, and it bites because your server sits somewhere far more privileged than a random person on the internet. From inside a cloud VPC it can reach things the attacker can't:

  • Cloud metadata endpoints. Every major cloud exposes a magic internal address, 169.254.169.254, that hands a machine its own details, including temporary credentials for the cloud account. Get the unfurler to fetch it and the server may hand back the keys. This is the same class of bug behind the 2019 Capital One breach, where an SSRF reached the EC2 metadata endpoint and walked off with data on more than 100 million people.
  • Internal-only services like admin dashboards, databases, and the Jenkins box: firewalled from the internet, wide open from inside.
  • Loopback and private ranges such as 127.0.0.1, 10.x, and 192.168.x.

This is the confused-deputy problem. Your server holds network access the attacker doesn't have, and the attacker talks it into spending that access on their behalf. Any feature where a server fetches a resource the user named is an SSRF surface: webhooks, PDF generators, image proxies, "import from URL" fields. The unfurler is just a clean example of the class.

Diagram of the confused-deputy problem: the attacker cannot reach the cloud metadata endpoint, but the server can, and the attacker chooses the URL the server fetches

The confused deputy. The attacker can't reach the metadata endpoint, but the server can, and the attacker picks the URL the server fetches.

What each prompt produced

Baseline validated almost nothing. It checked that the URL started with http or https, then let the HTTP client chase redirects wherever they led. Point it at the metadata endpoint and off it goes, no cleverness required.

Expert wrote real defenses into the first draft. It resolved the hostname and rejected private, loopback, and link-local addresses; pinned the connection to the address it had already validated, so the target couldn't swap after the check; re-checked every redirect hop; capped response size and time; and bound to localhost by default. Total findings dropped from 21 to 12. Two high-severity findings survived, but they're narrow bypasses of working defenses that take a specialist to reach. Baseline's high-severity bug took a single curl.

Inline-checklist got a sensible generic checklist: validate inputs, cap resources, handle errors. It cut the total by one and doubled the high-severity count. A checklist names the categories of defense without saying what actually attacks this app, so the model bolted on SSRF defenses and wired a specific bug into each one:

  • a DNS rebinding race: it resolved and checked the hostname, then let the HTTP client resolve it again at connect time. An attacker who runs the DNS answers with a safe public IP for the check and 127.0.0.1 for the connection. That's a TOCTOU bug (time-of-check to time-of-use), and baseline never had it, because baseline checked nothing.
  • an IP filter built as a hand-written deny list that lets whole special-use ranges through.
  • a synchronous DNS call sitting on the async event loop, stalling the entire server.
Diagram of a DNS rebinding attack: the check and the connection each resolve the hostname, returning a safe public IP at check time and a loopback address at connect time

DNS rebinding. The check and the connection resolve the hostname twice, and an attacker who runs the DNS answers "safe" for the check and "loopback" for the connection.

Baseline had a single broad hole. The checklist version swapped it for three narrow ones, each its own exploit. It bolted three shiny new deadbolts onto a door still hanging off its hinges. A half-finished defense can be worse than none, because it looks done and hands the attacker something specific to work on. What moved the numbers was a threat model tied to this app's real data flows. The word "security" in the prompt did nothing by itself.

Three traps even the good prompt missed

The expert prompt came from a security-aware author and still shipped bugs. They aren't random. Three traps caused them, and you'll hit the same three far outside URL unfurlers, so learn them by name.

Trap 1: enumerate a list where a rule would do. The prompt spelled out the bad IP ranges to block, and the model blocked exactly those, no more. It missed 100.64.0.0/10, the CGNAT range that Tailscale and Kubernetes overlay networks lean on (and that Python's is_private won't flag either). The fix a specialist reaches for is to test the underlying property: not ip.is_global asks whether an address can route on the public internet, and it catches CGNAT plus every range invented after you shipped. Any hand-maintained list, of file extensions or MIME types or domains or headers, misses the entry nobody thought of. Prefer one predicate that says what you actually mean.

Trap 2: compare two values that went through different parsers. The code pinned the connection to the validated IP, but only when the request's hostname matched the one it had validated. Those two hostnames came out of two different URL parsers, which disagree on internationalized domain names (bücher.example versus its punycode form xn--bcher-kva.example). On those names the check quietly failed, the pin got skipped, and the rebinding hole it was built to close yawned back open. A check that errs toward allowing is failing open, and open is the wrong default. When you validate a value and later compare it to the one in use, push both through the same normalizer first. The same mismatch sits behind a lot of path-traversal and Unicode auth-bypass bugs.

Trap 3: validate inputs and forget outputs. The threat model covered attacker → your service → internal target. It skipped attacker → your service → the value you return → your consumer → internal target. When a page's og:image pointed at an internal-only host, the service passed it straight through, and any downstream renderer that fetches that image becomes the confused deputy instead. The code even flagged this out of scope, which fit its too-narrow threat model. Every attacker-influenced value you return, store, or log is live input to whatever eats it next. Most stored-XSS and second-order injection lives right here.

Make the model derive the threat model

You can't reuse the expert prompt as a template. Its power was SSRF-specific, and pasted into a CSV importer it's just noise. The move that travels is to make the model build the threat model itself from a plain description of your system. You supply the procedure; the model fills in the domain.

Do this in the planning phase, before any implementation code. In Claude Code, that's literally plan mode.

Diagram of a threat-model-first planning pass: a generic template plus a plain system description yields domain-specific threats and defensive standards before any implementation code

The threat-model-first planning pass. The template stays generic, and the model supplies the domain-specific threats from your system description.

Step 1: describe the system in plain functional terms. Endpoints, inputs, outputs, data stores, external calls, and who calls it from where. Leave security out for now. That keeps the description honest and forces the next step to reason, so it can't just echo security notes you already wrote.

Step 2: run the security-engineer pass. Paste the prompt below right after your description. It says nothing about URLs or IPs; the model fills in the domain from what you wrote.

Now switch roles. You are a senior application-security engineer doing a
pre-implementation design review. Do NOT write implementation code yet. Work
only from the system description above.

Produce three sections:

A) APP-CLASS RISK PROFILE
   Classify what kind of system this is in security terms (e.g. "a service that
   fetches user-supplied resources," "a service that renders user-controlled
   content," "a service that runs user-influenced queries / commands," "a
   multi-tenant data store"). For that class, list the vulnerability categories
   that TYPICALLY bite this kind of app. For each, tie it to a SPECIFIC data
   flow in the description above and say why it applies here — not a generic
   checklist. If a category does not apply, say so and why.

B) THREAT MODEL
   - Trust boundaries: every point where data crosses from untrusted to trusted.
   - Attacker-controlled inputs: enumerate every value an attacker can influence
     — direct inputs AND indirect ones (responses from upstream services,
     redirects, resolved addresses/DNS, values you stored earlier and read back,
     filenames, headers).
   - Privileged position & assets: what can this code reach, do, or hold that an
     external attacker cannot reach directly (internal network, credentials,
     filesystem, other tenants' data, ability to send mail / spend money)?
   - Attacker goals: the 3–5 highest-value things an attacker would attempt,
     each mapped to the data flow that enables it.

C) DEFENSIVE-PROGRAMMING STANDARDS (the definition of done)
   For each risk in A/B, state the defense as an INVARIANT the code must uphold
   — an outcome, not a step. ("No operation may reach a resource the caller
   could not reach directly," not "call validate_ip().") Then commit to these
   general standards, which catch the bugs that survive even careful security
   work:
     1. PRIMITIVES OVER LISTS. Prefer a single library/stdlib predicate that
        expresses the security intent over a hand-maintained allow/deny list — a
        list silently misses the entry no one thought of. State what each check
        covers and what it does not.
     2. NORMALIZE BEFORE YOU COMPARE. If you validate a value and later compare
        it (or a transform of it) against a value in use, both sides must be
        produced by the SAME parser/normalizer. Mismatched parsing between
        validation and use is a classic bypass.
     3. VALIDATE OUTPUTS LIKE INPUTS. Any attacker-influenced value you return,
        store, log, or pass on — assume a downstream consumer will act on it, and
        validate it as if it were an input to that consumer.
     4. FAIL CLOSED. On any parse error, ambiguity, or unexpected state, deny
        rather than allow. Set explicit timeouts, size caps, and iteration
        limits. Choose safe defaults (least exposure, least privilege).
     5. NAME YOUR NON-GOALS. State what this component explicitly does NOT defend
        against, so the next person doesn't assume it does.

Treat section C as the acceptance criteria: the implementation is complete only
when every invariant in C is demonstrably upheld. Once I approve this, implement
against it.

Step 3: build against section C, then check against it. Hold the assistant to section C as the definition of done, and make it show you where each invariant is enforced. That's the step that turns a threat model into working defenses. Skip it and section C becomes a doc nobody reads.

This hands the model a real threat model, which is what won it for the expert prompt, and it derives that model from your system, which is what the checklist never had. Standards 1 through 3 are the three traps from earlier, written as rules you keep.

For code that matters, add one step. Take section C to a different model family (generate with one, audit with another) and tell it to attack the standard: what does this miss? A different training prior tends to catch what the first model structurally couldn't. Don't grade your own homework on the code that can get you popped.

Prior work, and where this fits

Most research on the security of AI-generated code measures the problem. "Asleep at the Keyboard?" (Pearce et al., IEEE S&P 2022) found about 40% of GitHub Copilot completions in security-relevant scenarios came out vulnerable, a 2021 number that newer models beat. Since then it's mostly benchmarks: CyberSecEval (Meta, 2023), SeCodePLT (NeurIPS 2025), BaxBench (ICML 2025), SecRepoBench (2025-26), SecureAgentBench (ACL 2026). They measure how insecure the code is, with static analysis, test oracles, and live exploits. Getting the model to write safer code up front gets a lot less attention.

Tony et al. (ACM TOSEM 2025) is the one dedicated, peer-reviewed study of prompting for secure code generation I found, and several of those benchmarks tack on a prompt-condition experiment as a side ablation. The pattern in them is consistent: security prompts help a lot on small self-contained tasks (SecRepoBench measured +19 points on one) and much less at repository scale (+1.6 points on the same study's real repos), and BaxBench calls its "tell the model the exact weakness to avoid" prompt an unrealistic upper bound. A separate line retrains or fine-tunes models for this (SVEN, SafeCoder, PromSec); I've set that aside, because the question here is what you can do with the model you already have, no training run required.

This work puts prompt content in the foreground and pins the task, model, and workflow in place. A later article in the series takes the question I couldn't find anyone answering head-to-head: does security guidance in the first prompt beat generating code and then repairing it with a scanner, holding the workflow constant.

Limitations

Read the numbers as a rough signal, with these caveats:

  • Sample size is one. One task, one model, one run per prompt. Model output wobbles between runs, so a rerun could land somewhere else. I'd read the 43% as "clearly fewer" and leave it there.
  • The graders are AIs too. They have their own blind spots, and some share a model family with the code generator, so they can agree or miss together. The obvious gaps, like baseline's wide-open SSRF against expert's closed one, are the part I trust. I trust the exact counts a lot less.
  • Favorable terrain. The task is small and self-contained, which is exactly where the published ablations show prompts helping most. At repository scale the same work says the effect shrinks, so expect a smaller win in a big codebase.
  • Prevention has a ceiling. Prompting clears the broad, directly exploitable bugs. It won't conjure knowledge the model lacks (CGNAT) or guarantee a clean implementation of a defense (the parser mismatch). Keep your reviews and your tests.
  • The workflow up top is an extrapolation. It stitches together what won for the expert prompt and fixes for what it missed, and I haven't run it through the experiment yet. Use it as a strong default until I do.

Cheat sheet

  • A threat model beats a checklist. Name the asset an attacker wants and the data flow that reaches it.
  • A half-built defense can be worse than none. If you add a defense, finish it.
  • Check three traps on every feature: a list where a rule would do, two values compared after different parsers, and outputs that leave the building unchecked.
  • Fail closed. Choose safe defaults. Say what you are not defending against.
  • Plan first: describe the system, run the security-engineer pass, build to the standards, and for high-stakes code get a second model family to attack them.

A later article in the series runs the two workflows head to head: security guidance in the first prompt against generate-then-scan-and-fix, same task, same scoring.

This post is published by the Monad Foundation for general informational and educational purposes only. It describes a single, limited experiment and does not constitute security, engineering, legal, or other professional advice. No representation or warranty is given as to the accuracy, completeness, or fitness for any purpose of the techniques, prompts, or findings described. Readers remain solely responsible for the security of their own systems and should obtain independent professional review before relying on this material. Third party names, products, and marks are the property of their respective owners and are used for identification purposes only; their use does not imply any affiliation with, or endorsement by or of, the Monad Foundation.

PreviousOpen sourcing Ultrafuzz
NextYou're at the latest
Ask about Monad

AI responses may contain errors. For anything material, contact our team directly.