Blog
How to ask an AI for secure code
Six practices set up once that act on every change, so the assistant writes correct code from the start instead of accumulating defects.
- 17 min read
The volume of code an assistant produces per unit of time exceeds what a team can review in that same time. That mismatch, rather than the model’s success rate, determines how many defects reach production.
In March 2026 Veracode tested more than 150 models against 80 coding tasks in four languages. 55% of those tasks produced secure code1. A peer-reviewed study generated 1,689 programs five years earlier and found roughly 40% of them vulnerable2. The models behind the two figures have nothing in common, and the numbers are not comparable to each other. Neither reaches 100%.
The defects that pass that filter produce no signal. The code compiles, the application starts and the tests pass, so nothing separates the correct part from the rest. Without a review tied to each change, defects are not caught one at a time: they accumulate until somebody audits the whole tree, which is the most expensive way to find them.
What follows are six practices set up once that act on every change. None of them depends on the model having followed an instruction. Another article on this blog lists seven failures to look for by hand in a diff; this one is about not having to look for them.
Every piece of code was rebuilt for this article. Measured versions: Node.js 24.19.0, TypeScript 6.0.3, Zod 4.5.4, ESLint 10.10.0 with typescript-eslint 8.70.0, Slonik 49.10.9 and eslint-plugin-security 4.0.1.
The TypeScript version is not the latest one, and the reason is part of point 5. typescript-eslint 8.70.0 declares typescript: '>=4.8.4 <6.1.0' in its peer dependencies, so installing it alongside TypeScript 7.0.2 fails with ERESOLVE, and forcing it makes the linter abort with typescript-eslint does not support TS 7.0. The rule from point 5 cannot run on the most recent version of the compiler: the range the linter supports decides the TypeScript version, not the other way round.
1. A rule reaches the model only if something decides to load it
Writing the project’s rules does not guarantee they are applied. A rule influences the result only when it is in the context window at the moment of the change. What decides whether it gets there is not the topic it was filed under, but the trigger attached to it.
The two most used tools solve this in similar ways. Claude Code loads rules in three levels. The name and description of every rule are always present, and cost around 100 tokens each. The body is read only when the request matches that description, and is recommended to stay under 5,000 tokens. Bundled files cost nothing until they are opened3. The documentation is explicit that the description has to say what the rule does and when to apply it, because it is the only text the decision is made against4. Cursor offers four modes and two of them are automatic: one decides by the description, the other by the file patterns the change touches5.
The practical consequence is that rules are split by trigger moment, not by topic. A rule filed under “security” does not load when somebody writes a test; one filed under “when writing a test” does.
The second half of the principle matters as much as the first. Anthropic’s documentation states it plainly: the context window is a public good, and every paragraph of a rule competes with the conversation and with the work in progress4. Only information the model does not already have belongs there. A rule explaining what a parameterised query is takes up space without contributing anything; one stating which client this repository uses contributes a fact the model cannot infer.
The difference between the two ways of organising can be measured. Vercel’s public skills repository publishes each guide in two formats: an index, and the concatenation of all its rules. In react-best-practices, checked on 7 September 2026, the index takes 7,251 bytes and the concatenation 108,261, spread across 72 rule files averaging 1,535 bytes each6. Reading the index plus the two rules a change touches costs around 10,000 bytes. Reading the concatenation costs ten times that to bring in seventy rules nobody needed.
How to check it. Concatenate what the assistant always loads — the base instruction file plus the descriptions of every rule — and count the characters. That number is the fixed cost of every session. Then, for each rule, name the moment it should fire; if the answer is “always”, it belongs in the base instructions and has to be short.
2. Every rule carries the failure that produced it and the command that detects it
A rule stated as a preference admits argument. “Prefer parameterised queries” admits exceptions the model will justify reasonably, and whoever reviews the change has nothing to contradict it with. A rule that brings the concrete failure and the command that finds it does not allow that conversation.
The shape that works has five fixed parts:
- The mechanism of the defect. What the incorrect code does, not what it is called.
- The case that cost something. A real, locatable example, so the rule does not read as an opinion.
- The rule, in one sentence.
- A bad and good pair, with no comments saying which is which. The code has to be distinguishable on its own.
- The command that detects it again: a
grep, a lint rule or a test.
The fifth part is what separates a useful rule from a recommendation. Without it there is no way to check whether the tree already breaks the rule elsewhere.
It also helps to write the check before the rule. Anthropic’s authoring guide recommends this as a method: first measure what fails without the rule, and only then write the minimal instruction that fixes it4. Writing it the other way round produces rules for problems that never happened.
How to check it. Run the detection command against the bad example and the good one. It has to find the first and not the second. If it does not distinguish between them, the rule has no detector and another one has to be written.
3. Review happens per change, with an instruction to knock things down
Google’s engineering guides set the order of magnitude: 100 lines is a reasonable size for a change and 1,000 is usually too large. The reasons given are not about style. A small change is reviewed sooner, because finding five minutes several times a day is easier than setting aside half an hour. And it is reviewed better: in a large change the volume of comments makes the important ones get lost. The document goes as far as saying a reviewer may reject a change for the sole reason of being too large7.
With an assistant that limit stops being natural. An 800-line change costs the same to ask for as an 80-line one, so the size has to be set deliberately and before starting.
The second part is what the reviewer is asked to do. The outcome of a review depends on that instruction: asking for an approval produces reports that approve. The instruction that produces findings is the opposite one, to look for what does not hold up. An empty report requires checking the coverage of the review before it is accepted.
That instruction needs a counterweight written next to it, because without one the review becomes harmful. A false positive costs an unnecessary change to code that worked. Mechanical tools that detect dead or duplicated code serve as hints and never as sources: they do not see re-exports or scripts invoked from the command line, so they mark as dead what is in fact used. Every claim of the form “this is not used” is verified in the file before being written down.
How to check it. Every review is tied to the hash of the commit it reviewed. A review with no hash cannot be repeated or contrasted, so in practice it did not happen.
4. A test is accepted once it has been seen to fail
An assistant asked for tests produces them, and they generally pass. The problem is that it writes them after the code, so they tend to describe what that code does rather than check what it should do. A test that passes with the defect present and also with the defect fixed does not distinguish between the two states.
The discipline that solves this has its own name and vocabulary. In mutation testing a defect is introduced into the production code and the tests are run: if one fails, the mutant is killed; if they all pass, the mutant survives, and a surviving mutant marks a gap in the tests. Stryker’s documentation contrasts this with coverage, which measures which lines run and not whether the tests validate behaviour8.
The manual version, which needs no tool, is measured in two directions before a test is accepted.
- Red direction. Reintroduce the exact defect the test claims to cover, without renaming anything, and the test has to go red. If it stays green, it defends nothing.
- Green direction. Rename something without changing the semantics — a local variable, a parameter, a type — and the test has to stay green. If it goes red, it bites names and not behaviour.
The mutation is always made in the production code and never in the test file. If the test has to be edited to make it fail, it was not checking anything.
// orders.mjs
export function getOrder(orders, id, userId) {
const order = orders.find((o) => o.id === id);
if (!order) return null;
if (order.userId !== userId) return null;
return order;
}
// orders.test.mjs
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getOrder } from './orders.mjs';
const orders = [{ id: 'a1', userId: 'ana' }];
test('returns the order to its owner', () => {
assert.equal(getOrder(orders, 'a1', 'ana')?.id, 'a1');
});
test('does not return the order to another user', () => {
assert.equal(getOrder(orders, 'a1', 'beto'), null);
});
With the ownership check in place, node --test reports pass 2 and exits 09. Removing the line that compares userId makes the second test fail:
✔ returns the order to its owner
✖ does not return the order to another user
ℹ pass 1
ℹ fail 1
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ { id: 'a1', userId: 'ana' }
- null
How to check it. Revert the fix, run the tests, confirm the new one fails. Then apply the fix again.
5. What the code can enforce is not written as a rule
A prompt rule and a compiler check do not compete: they do different things. The rule influences and the check decides. When breaking something can be detected in the syntax tree or in the output, writing it as a rule occupies context with something already settled.
Two examples, each with the full pattern.
Validate at the boundary. “Validate inputs” is a rule. The version that decides is that the domain function only accepts validated data.
import { z } from 'zod';
export const CreateOrder = z.object({
customerId: z.uuid(),
amount: z.number().positive()
});
export type CreateOrder = z.infer<typeof CreateOrder>;
export function createOrder(data: CreateOrder): string {
return `${data.customerId}:${data.amount}`;
}
z.infer derives the type from the schema, so the type and the validation are one declaration. A route that passes the request body unvalidated does not compile:
route.ts(5,34): error TS2345: Argument of type 'unknown' is not assignable
to parameter of type '{ customerId: string; amount: number; }'.
One way out remains, and it has to be closed. Writing body as CreateOrder is a type assertion: it tells the compiler to treat the value as if it were of that type and generates no check. With it, tsc --noEmit ends in 0 and the unvalidated data reaches the function. The rule @typescript-eslint/no-unsafe-type-assertion reads the same type information tsc does and disallows assertions that narrow a type10, so the line the compiler accepts is the one the linter rejects:
5:34 error Unsafe type assertion: type '{ customerId: string; amount: number; }'
is more narrow than the original type
@typescript-eslint/no-unsafe-type-assertion
✖ 1 problem (1 error, 0 warnings)
Remove the unsafe path. SQL injection happens when text sent by the user ends up forming part of the query the database parses. A client with tagged templates prevents that by design: the template hands the function the fixed text and the values as separate arguments, and the values travel apart until the query has already been parsed.
import { sql } from 'slonik';
const filter = "1 OR 1=1";
const query = sql.unsafe`select * from orders where customer = ${filter}`;
console.log(query.sql); // select * from orders where customer = $slonik_1
console.log(query.values); // [ '1 OR 1=1' ]
The hostile text stays in the list of values. Building dynamic SQL means calling a different function, sql.identifier, which also quotes what it writes, and that call can be found with a search.
How to check it. Write both incorrect cases deliberately and run them through both tools, because each catches the one the other lets through. The unvalidated body stops tsc with exit 2, and the linter says nothing. The as assertion passes tsc with exit 0, and the linter stops it with exit 1. Running only one of the two leaves exactly one of the two routes open.
6. What runs without anyone asking
The practices above only act when somebody applies them. In continuous integration they apply on every change, without depending on anyone to remember. They come to five steps, counting the two that do not depend on the code just written.
- Compilation.
tsc --noEmitacross the project. It holds up section 5 and finds anywhere else unvalidated data reaches a function that expects validated data. - Type-aware linting. It prevents the assertion from section 5 and locates the calls that build dynamic SQL, which are the ones to read one by one.
- Tests. The green run automates without trouble. The condition from section 4, that the test has been seen to fail, is checked when the change is reviewed, because it requires the code from before the fix.
- Dependencies. Installing runs third-party code, and the lockfile decides what gets installed. That has an article of its own and is not repeated here.
- Secrets. GitHub blocks a push when it detects a credential. On public repositories the protection is on by default for user accounts; on private ones it has to be enabled and requires GitHub Secret Protection11.
None of the five steps asks where the code came from. They apply the same way to a change written by hand and to one generated by a model.
The same commands allow two earlier points of execution. The first is the assistant’s own loop: agent harnesses let a command be hooked after every file write, and putting the compiler there stops the model from building on code that no longer compiles. The second is the pre-commit hook, which runs before the change exists as a commit. Neither replaces continuous integration, because both live on the machine of whoever is programming and both can be skipped. What they do is shorten the interval between the defect and the warning.
How to check it. Open a change containing one failure of each kind and confirm that CI stops it. A check that has never been seen to fail is not verified, for the same reason a test that has never failed is not.
What no control detects
The six practices escalate in power. The first depends on something loading the rule, and the last runs without anyone asking. That scale has a floor, and it is worth saying where it is.
Broken access control is found by none of the tools above. No signature distinguishes getOrder(orders, id, userId) from getOrder(orders, id). Both compile, both pass the linter, and the missing check leaves no trace in the syntax tree. A static analyser finds the query that concatenates text; it does not find the correct query that is missing a filter.
In that class, and in the others that depend on business rules, practices 3 and 4 carry the whole weight alone. The review tied to the change and the test seen to fail are the only controls left, which is why point 4 uses exactly that example.
There is a cheap way to reduce the surface. Make ownership part of the query, inside the where, instead of checking it on a later line. The condition then travels with the query, and a later check can be forgotten in the next endpoint. The other article works it through with the code.
How to check it. With one user’s session, request an identifier that belongs to another. The correct answer is 404. If the record arrives, none of the six practices was going to stop it.
What stays in the request
Writing good requests still has an effect. The model makes decisions no check can make for it: what the person opening the issue actually wants, which part of the system needs touching, what the new thing should be called, and how much detail the answer needs. Those decisions are semantic and have no check attached, so the request is the only place they fit.
Four questions decide where a new rule goes:
- If breaking it can be detected in the syntax tree or in the output, the rule goes in the code.
- If it describes an algorithm with steps, it goes in a script or a typed function.
- If it is a decision about which, about naming, or about how something reads, it goes in the request and stays as short as possible.
- If it contradicts an example in the same file, the example wins, because the model reads both and the example is more concrete.
Setting up the six practices costs a few hours, and after that they cost minutes per change. The alternative is finding the same defects later, by reading the whole tree, which is exactly the situation Google’s guides describe as the one that gets reviewed worst.
References
- Veracode. Spring 2026 GenAI Code Security Update24 March 2026; more than 150 models, 80 tasks, four languages.veracode.com↩
- Pearce, Ahmad, Tan, Dolan-Gavitt and Karri. Asleep at the Keyboard? Assessing the Security of GitHub Copilot's Code ContributionsIEEE Symposium on Security and Privacy 2022; 1,689 programs across 89 scenarios.arxiv.org↩
- Anthropic. Agent Skills, overviewloading levels, token cost and security considerations.platform.claude.com↩
- Anthropic. Skill authoring best practicesthe description, conciseness, and evaluations before documentation.platform.claude.com↩
- Cursor. Rulescursor.com↩
- Vercel. agent-skills, skills/react-best-practicessizes measured on 7 September 2026 with the GitHub API.github.com↩
- Google. Engineering Practices, Small CLsgoogle.github.io↩
- Stryker Mutator. Mutation testingstryker-mutator.io↩
- Node.js. Test runnernodejs.org↩
- typescript-eslint. no-unsafe-type-assertiontypescript-eslint.io↩
- GitHub Docs. About push protectiondocs.github.com↩