Alex HerreraSoftware developer · Cybersecurity focus
ES
← Back to the blog

Web security

How to ask an AI for secure code

What to put in the request to get secure code from an AI, and what to check afterwards.

  • AI
  • Security
Published
Updated
Reading time
18 min read

Asking a model to write secure code changes the result very little, except in reasoning models. BaxBench measured 392 backend tasks with three versions of the same request1:

  1. with no security instruction at all;
  2. with a generic reminder: “follow best practices for security and avoid common vulnerabilities”;
  3. with the exact list of vulnerabilities that task could have.

The generic reminder improved the reasoning models a great deal and did not improve the others significantly. The exact list gave the best security result in every model, but it reduced the number of programs that worked1. The authors warn that such a list is almost impossible to know in advance on a real project1.

Veracode measures requests that do not mention security. In its March 2026 update, with more than 150 models, 55% of the samples were secure, the same proportion as two years earlier2.

The two measurements lead to two practices. The request has to name the specific risk of the task, not security in general. And whatever the request does not guarantee has to be checked by a command: the compiler, the linter or a test that has been seen to fail.

All the code is rebuilt for the article. The measurements were taken on 27 September 2026 with Claude Code 2.1.283, Node.js 24.19.0, Express 5.2.1, TypeScript 6.0.3, Zod 4.6.5, ESLint 10.11.0 and typescript-eslint 8.70.1. That last version supports TypeScript up to 6.0, so the linter in section 4 does not yet work with TypeScript 73.

1. What to put in the request

The model infers what the code shows, such as the name of a field or a convention that repeats. The request has to bring what the code does not show:

  • who owns each piece of data and where the session user is;
  • what each error case answers, such as someone else’s resource or a request without a session;
  • the done criterion, with the test that proves it.

To measure it, the same task was sent twice to Claude Code, each time on a clean copy of an Express project. The project keeps the orders in memory, with a usuarioId field, and puts the session user in req.usuario. The first request describes only the feature:

Add to src/app.js a GET /pedidos/:id route that returns the order.

The second adds the owner, the error cases and the criterion:

Add to src/app.js a GET /pedidos/:id route that returns the order.
Each order belongs to a user (usuarioId field), and the session user is in
req.usuario. Only its owner may read it: an order belonging to another user
answers 404, the same as one that does not exist. Without a session, 401.
Criterion: npm test passes with a new test that requests p2 with ana's
session and expects 404.

The measurement used the Spanish version of both requests. Both versions filtered by owner, because the usuarioId field was in plain sight in the same file. The difference was in the check. The first run wrote no test and ended with “I have not tested it”, because its quick check needed an approval that non-interactive mode does not give. The second created test/pedidos.test.js with four cases, although it could not run them for the same reason; when they were run afterwards, npm test passed.

When the comparison with usuarioId was removed from the code, the test “an order belonging to another user answers 404” failed. The second run also warned that the header that simulates the session can be forged.

One run of each request does not measure how often each result happens. It does show what the criterion adds: a command that proves the behaviour and keeps working when someone changes the route.

How to do it.

  1. Write in the request who owns the data the task touches and where the user comes from.
  2. Write the answer for each error case.
  3. End with the criterion: the command and the result it has to return.
  4. Write in the project’s instructions file, and not in every request, what repeats across all tasks, such as the database client or the error format.

How to check it. Before sending the request, look for an owner, an error case and a command in it. If the command is missing, you will have to read the whole change to decide whether it is done.

2. How to make a rule load when it is needed

A rule is an instruction for the agent stored in a file that the harness loads into the session. The harness is the program that runs the agent, such as Claude Code or Cursor. A rule is for what repeats across every task in one part of the code. It only affects the result if it is in the context window at the moment of the change. The context window is the text the model has available in the session.

The harness loads each rule according to its trigger, the condition that activates it. Claude Code has two triggers that do not depend on someone naming the rule:

  1. Rules in .claude/rules/ with a paths header load when the agent works with a file that matches those paths4.
  2. Skills load by their description. The name and description of each one take about 100 tokens and always load; the body is read only when the request matches the description5.

Cursor has the same two triggers: by file pattern or by description6.

So split rules by the moment they need to load, not by topic. A rule about how API input is validated goes in a file whose paths point at the API routes:

---
paths:
  - "src/api/**/*.ts"
---

- Every route validates the body with the Zod schema in src/esquemas before using it.
- Another user's resource answers 404, not 403.

According to Anthropic’s guide, the context window is a shared resource. Every paragraph of a rule takes space that the conversation and the work in progress need7. So a rule should only contain what the model does not already know. A rule that explains what a parameterised query is adds nothing. One that says which database client the repository uses adds a fact the model cannot infer.

Vercel’s public skills repository makes it possible to measure the difference between loading only what is needed and loading everything. It 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 7251 bytes and the concatenation 108,261, spread over 72 rule files8. Reading the index and the two rules that affect a change takes about 10,000 bytes, a tenth of the total.

How to check it. In Claude Code, open a file that matches paths, ask for a change and run /context. The rule has to appear in the session. Then ask for a change in a file that does not match: the rule must not load.

3. How to write a rule that can be checked

A rule written as a preference invites argument. Faced with “Prefer parameterised queries”, the model can justify reasonable exceptions, and whoever reviews the change has nothing to contradict it with. A rule that brings the specific failure and the command that detects it leaves no such room.

A useful rule has five parts:

  1. The mechanism of the defect: what the wrong code does, not what the failure is called.
  2. A real case that can be located, so that the rule does not read as an opinion.
  3. The rule, in one sentence.
  4. A wrong example and a right one, with no comments saying which is which, because the code has to tell them apart by itself.
  5. The command that detects the failure again: a grep, a linter rule or a test.

The fifth part is what makes the rule checkable. Without it, there is no way to know whether the code already breaks the rule in other files.

Write the check before the rule. Anthropic’s guide recommends it: first measure what fails without the rule, and then write the minimal instruction that fixes it7. Done the other way round, you write rules for problems that never happened.

How to check it. Run the detection command on the wrong example and on the right one. It has to find the first and not find the second. If it cannot tell them apart, the rule has no detector: write another.

4. What to check with the compiler and the linter

If a violation can be detected in the code without running it, do not leave it as a rule: make the compiler or the linter detect it. A rule influences the model, but the compiler and the linter decide whether the code passes.

Validate at the boundary. The domain function, the one that applies the business logic, only accepts data that has already been validated. The Zod schema declares the validation and the type at once:

// src/pedido.ts
import { z } from 'zod';

export const CrearPedido = z.object({
  clienteId: z.uuid(),
  monto: z.number().positive()
});

export type CrearPedido = z.infer<typeof CrearPedido>;

export function crearPedido(datos: CrearPedido): string {
  return `${datos.clienteId}:${datos.monto}`;
}

The type is not enough. req.json() returns Promise<any>, and Express’s req.body is any too. An any value is accepted by any parameter, so this route, which passes the body without validating it, compiles without errors:

// src/ruta.ts
import { crearPedido } from './pedido.js';

export async function POST(req: Request) {
  return crearPedido(await req.json());
}

In the measurement, tsc --noEmit exited with code 0. The linter rule @typescript-eslint/no-unsafe-argument detects exactly that case: an any value reaching a typed parameter9. It is part of typescript-eslint’s recommended-type-checked configuration.

The type assertion remains. Writing cuerpo as CrearPedido tells the compiler to treat the value as if it had that type, and it generates no check:

// src/ruta-as.ts
import { crearPedido, type CrearPedido } from './pedido.js';

export async function POST(req: Request) {
  const cuerpo: unknown = await req.json();
  return crearPedido(cuerpo as CrearPedido);
}

tsc --noEmit accepted this route too. The rule @typescript-eslint/no-unsafe-type-assertion forbids assertions that narrow a type10. It is in neither the recommended nor the strict configurations, so it has to be turned on by hand:

// eslint.config.js
import tseslint from 'typescript-eslint';

export default tseslint.config(
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: { parserOptions: { projectService: true } },
    rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error' }
  }
);

With that configuration, the linter rejected both routes the compiler had accepted:

src/ruta-as.ts
  5:22  error  Unsafe type assertion: type '{ clienteId: string; monto: number; }'
               is more narrow than the original type
               @typescript-eslint/no-unsafe-type-assertion

src/ruta.ts
  4:22  error  Unsafe argument of type `any` assigned to a parameter of type
               `{ clienteId: string; monto: number; }`
               @typescript-eslint/no-unsafe-argument

✖ 2 problems (2 errors, 0 warnings)

The route that validates passes both checks:

// src/ruta-ok.ts
export async function POST(req: Request) {
  return crearPedido(CrearPedido.parse(await req.json()));
}

How to check it. Write the two wrong routes on purpose and run tsc --noEmit and the linter. tsc has to exit 0 and the linter 1, with one error per route. If the linter says nothing, the type information (projectService) is missing or the rule is not turned on.

5. How to check that a test detects the defect

If you ask an agent for tests, it writes them, and they usually pass. When it writes them after the code, they tend to describe what the code does and not what it should do. A test that passes with the defect and without it does not tell the two states apart.

Mutation testing measures that. It introduces a defect, called a mutant, into the production code and runs the tests. If any test fails, the mutant is killed; if they all pass, the mutant survives and points to behaviour the tests do not check. Stryker’s documentation distinguishes it from coverage, which measures which lines run and not whether the tests check the behaviour11.

The manual version needs no program. Before accepting a test, check it in two directions:

  1. Red direction. Reintroduce the exact defect the test claims to cover, without renaming anything. The test has to fail; if it still passes, it protects against nothing.
  2. Green direction. Rename something without changing the behaviour, such as a local variable, a parameter or a type. The test has to keep passing; if it fails, it checks names and not behaviour.

Always make the mutation in the production code, never in the test file. If you have to edit the test for it to fail, the test was checking nothing.

// pedidos.mjs
export function obtenerPedido(pedidos, id, usuarioId) {
	const pedido = pedidos.find((p) => p.id === id);
	if (!pedido) return null;
	if (pedido.usuarioId !== usuarioId) return null;
	return pedido;
}
// pedidos.test.mjs
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { obtenerPedido } from './pedidos.mjs';

const pedidos = [{ id: 'a1', usuarioId: 'ana' }];

test('devuelve el pedido a su dueno', () => {
	assert.equal(obtenerPedido(pedidos, 'a1', 'ana')?.id, 'a1');
});

test('no devuelve el pedido a otro usuario', () => {
	assert.equal(obtenerPedido(pedidos, 'a1', 'beto'), null);
});

With the owner check, node --test returns pass 2 and exits with code 012. When the line that compares usuarioId is removed, the second test fails:

✔ devuelve el pedido a su dueno
✖ no devuelve el pedido a otro usuario
ℹ pass 1
ℹ fail 1

AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ { id: 'a1', usuarioId: 'ana' }
- null

Ask the agent for both directions in the task’s criterion, as in the second request in section 1.

How to check it. Revert the fix, run the tests and check that the new test fails. Then apply the fix again.

6. How to review every change

Google’s engineering guides set the order of magnitude: 100 lines is a reasonable size for a change, and 1000 is usually too much. A small change gets reviewed sooner and better, because in a large change the important comments get lost among the rest. According to the document, a reviewer may reject a change just for being too large13.

With an agent, the time it takes to write no longer limits the size of the change: asking for 800 lines costs the same as asking for 80. Set the size in the request.

Ask the reviewer, whether a person or an agent, to look for what does not hold up and to write down what it checked for each finding. Write its limit too. Programs that detect dead or duplicated code are only good for guiding the search. They do not see re-exports or scripts run from the command line. Before writing in the report that something is unused, check it in the file.

How to check it. Record in every review the hash of the commit it reviewed. Without that hash, nobody can repeat the review or compare it with the code.

7. How to run the checks on every change

The checks above only work if someone runs them. In continuous integration, the server that checks every change pushed to the repository, they always run. There are five steps:

  1. Compilation. Run tsc --noEmit on the whole project.
  2. Linter with type information. It applies the two rules from section 4.
  3. Tests. Automate running them. The condition from section 5, having seen the test fail, is checked in review, because it needs the code from before the fix.
  4. Dependencies. Installing runs third-party code, and the lockfile decides what is installed. The article npm vs pnpm 11: install script security and allowBuilds explains how to configure it.
  5. Secrets. GitHub blocks pushing a commit when it detects a credential. On public repositories that protection is on by default for user accounts. On private ones it has to be turned on, and it requires GitHub Secret Protection14.

None of the five steps can tell whether a person or a model wrote the change, so they apply to both alike.

The same commands can also run at two earlier moments:

  1. After every edit by the agent. The harness can run a hook, a command at a fixed moment, every time the agent writes a file. If it runs the compilation there, the model does not keep building on code that no longer compiles.
  2. Before every commit. The pre-commit hook runs the commands before the change exists as a commit.

Neither replaces continuous integration, because both run on your machine and can be skipped. They make the warning arrive sooner, closer to the moment the defect was introduced.

How to check it. Open a change that contains one failure of each kind and check that continuous integration stops it. A control that has never been seen to fail is not verified, just like a test that has never failed.

Which failures static analysis does not detect

The compiler and the linter do not find broken access control. No pattern tells obtenerPedido(pedidos, id, usuarioId) apart from obtenerPedido(pedidos, id): both versions compile and pass the linter, and the missing check leaves no trace in the code. A static analyser finds the query that concatenates text, but not the correct query that is missing a filter.

That failure is detected by a test with two users that has been seen to fail, like the one in section 1, and by reviewing every change. The database can also enforce it with row-level security, which filters every query by the connection’s user. In exchange, every request has to open the connection with the user’s identity, and the rule ends up split between the application and the database.

If authorisation is in the application, include the owner in the query condition, inside the where. Section 2 of Eight things to check in the code your AI writes shows it with code.

How to check it. With one user’s session, request an identifier that belongs to another. The right answer is 404. If the record comes back, neither the compiler nor the linter would have stopped it.

Where to go next

Eight things to check in the code your AI writes teaches how to look for eight common failures by hand. For each one it gives the fix and the command that checks it.

How to secure a web application gathers the ten layers these rules and checks have to cover in an application with users.

References

  1. Vero, M., Mündler, N., Chibotaru, V., Raychev, V., Baader, M., Jovanović, N., He, J. and Vechev, M.. BaxBench: Can LLMs Generate Correct and Secure Backends?ICML 2025; 392 tasks and three versions of the request.arxiv.org↩
  2. Veracode. Spring 2026 GenAI Code Security Update24 March 2026; more than 150 models, 80 tasks, four languages.veracode.com↩
  3. typescript-eslint. Dependency VersionsTypeScript >=4.8.4 <6.1.0.typescript-eslint.io↩
  4. Claude Code. How Claude remembers your project, Path-specific rulescode.claude.com↩
  5. Anthropic. Agent Skills, overviewloading levels, token cost and security considerations.platform.claude.com↩
  6. Cursor. Rulescursor.com↩
  7. Anthropic. Skill authoring best practicesthe description, conciseness and evaluations before documentation.platform.claude.com↩
  8. Vercel. agent-skills, skills/react-best-practicessizes measured on 7 September 2026 with the GitHub API.github.com↩
  9. typescript-eslint. no-unsafe-argumenttypescript-eslint.io↩
  10. typescript-eslint. no-unsafe-type-assertiontypescript-eslint.io↩
  11. Stryker Mutator. Mutation testingstryker-mutator.io↩
  12. Node.js. Test runnernodejs.org↩
  13. Google. Engineering Practices, Small CLsgoogle.github.io↩
  14. GitHub Docs. About push protectiondocs.github.com↩