← Back to BlogSecurity

Secure Defaults: Making AI Write Safe Code by Design

AI coding assistants default to insecure patterns because training data mixes safe and unsafe code. How to flip the default toward secure code by design.

Ask an AI coding assistant to write a login handler, a search endpoint, or a script that shells out to a system command, and it produces something that runs on the first try. It also, with uncomfortable frequency, produces something broken in a specific way: a query built with string concatenation, an authorization check that only covers the happy path, a secret typed directly into the file it just created. None of this is random. The model is completing a pattern from its training data, a mix of every public code sample ever indexed, safe and unsafe, sitting next to each other with nothing distinguishing one from the other.

A controlled study out of Stanford found that developers using an AI code assistant wrote measurably less secure code across tasks involving cryptography and untrusted input, and were simultaneously more likely to believe their code was secure. That combination, weaker code paired with higher confidence, is a failure mode review alone cannot fully close, since the person reviewing is often the same person who trusted the output in the first place. The more durable fix sits earlier: change what the assistant reaches for by default, and reserve review for what a changed default still misses.

Sources: Do Users Write More Insecure Code with AI Assistants? (Stanford, ACM CCS 2023)

Why the First Draft Defaults to Insecure

Pattern completion has no opinion about risk. When a model has seen a hundred examples of a SQL query built with an f-string for every one built with a bound parameter, the more common pattern surfaces first, simply because it is the path of least resistance through the training distribution. The same asymmetry shows up in authorization checks that exist on one route but were never copied to a near-duplicate one, in hashing calls that use a fast, general-purpose function instead of one built for passwords, and in configuration scaffolding that writes a placeholder credential straight into the file.

This is also why the mistake does not stay isolated. Coding assistants stay consistent within a session, so a pattern picked at the first call site tends to reappear at every similar one across the same feature, which is part of what makes a single miss in review expensive: it rarely stays a single miss. The rest of this piece covers the earlier lever, changing what the assistant reaches for by default, so fewer instances of the pattern get written in the first place.

Parameterize Queries, Don't Ask for Care

SQL injection remains one of the most consistently ranked risks in the industry's own accounting of what breaks web applications. The fix is not asking the assistant to "be careful with user input"; that instruction is vague enough to be satisfied by an escaping function that misses an edge case. The fix is naming the construct to reach for: a parameterized query, an ORM's query builder, or a safe API that keeps the interpreter from ever seeing raw, unescaped input.

Sources: OWASP Top 10:2021 - A03 Injection

  • A query built with an f-string or `+` concatenation should never reach the database layer, regardless of whether the input looks safe
  • Prepared statements and bound parameters keep data separated from the query structure the interpreter executes
  • An ORM or query builder that defaults to parameterization removes the decision from every individual call site
  • The same logic extends past SQL: shell commands should use an argument array instead of a shell interpreter, and templates should use an engine with auto-escaping on by default

Deny-by-Default Authorization, Not Allow-by-Default

Broken access control tops the same industry ranking, and the version that shows up most in AI-generated code is subtle: a handler checks that a user is logged in, then fetches a record by ID without checking that the user actually owns it. The check that exists looks reasonable in isolation. What is missing is the check nobody wrote, because a functional test never catches its absence: the happy path, with the requesting user's own data, passes every time.

Sources: OWASP Top 10:2021 - A01 Broken Access Control

The structural fix is to make denial the default state, not an opt-in. A central authorization layer, applied at the routing level rather than hand-copied into each handler, means a new endpoint is unauthorized until someone explicitly grants it a rule. List and search endpoints need the same ownership filter a single-record lookup gets; an assistant asked to add an endpoint "like the others" will often copy the URL and response shape while dropping the authorization line that made the original one safe.

Secrets Don't Belong in the File the Assistant Just Wrote

Public training data is full of example code with a literal API key or password sitting in a config file, usually placeholder text never meant to ship, and an assistant scaffolding a new integration often reaches for the same shape: a constant holding a real-looking credential. It is a fast way to get a demo running and an easy way to commit a secret to source control, since nothing forces the placeholder to get replaced with a real secret-loading step.

The fix is one pattern to use every time a credential is needed: read from an environment variable, a secret manager, or the project's config-loading utility, never a literal string. State it as a standing rule: no file it writes should ever contain a real or placeholder-shaped credential. A `.env.example` file with dummy keys and a git-ignored `.env` for the real ones costs nothing to set up once and removes the decision from every future session.

Give It the Safe Library, Not Just the Rule

A written rule competes with everything else in the model's context and can get deprioritized over a long session. A pre-selected library or utility function does not compete the same way, because using it is usually the shortest path to a working answer anyway. If the authentication utility already wraps a slow, purpose-built password hash, and the query layer wraps parameterized calls behind a small helper, the fastest route to done and the secure route become the same route.

Insight

The gap is not a flaw in the model. It is the model completing whatever pattern the surrounding project leaves open. Give it a secure pattern to complete instead of an insecure one to avoid, and the default output changes without anyone having to catch the mistake later.

  • A shared query helper that only exposes a parameterized call, with no string-building path to reach for
  • A password utility that wraps bcrypt or argon2, so hashing is one function call instead of a decision
  • A subprocess wrapper that only accepts an argument list, never a raw shell string
  • A config loader that throws on a missing environment variable instead of silently accepting a hardcoded fallback
Pro Tip

Add one line to the project's persistent instructions, a CLAUDE.md, system prompt, or house-rules file, naming the exact secure primitive for each risky operation: "database access always goes through db.query(), never raw SQL." A rule tied to a specific function changes the default; a generic instruction to write secure code rarely does.

AreaInsecure defaultSecure default
Database queriesString-concatenated or f-string SQL passed straight to the driverParameterized queries, prepared statements, or an ORM query builder
Authorization checksOwnership check present on the single-record route, missing on list/searchCentral deny-by-default middleware applied at the routing layer
Secrets and credentialsAPI key or connection string written as a literal in source or configRead from an environment variable or secret manager, never hardcoded
Shell commandsCommand string built by concatenation and passed to a shell interpreterArgument array passed to a subprocess API that bypasses the shell
Password hashingFast general-purpose hash, such as plain SHA-256, used for password storageSlow, purpose-built hash such as bcrypt or argon2, via a wrapped utility
Template outputRaw string interpolation into HTML outputAuto-escaping template engine enabled by default

The Adversarial Gate Still Has to Run

None of this removes the need for review. A shifted default lowers how often an insecure pattern shows up in the first draft; it does not guarantee zero, especially on a task that does not map onto any pre-approved helper, or a prompt ambiguous enough that the model falls back on the wider pattern space it was trained on. The same missing boundary shows up in prompt injection in AI-generated code: an insecure default and an injection vulnerability both trace back to safe and unsafe patterns never being kept structurally apart, and both need a check that runs after the code exists, not only a rule that runs before it.

That check works best as a dedicated adversarial pass, separate from the review that confirms a feature works, scoped to the vulnerability classes secure defaults are meant to prevent. How to run a security review of AI-written code covers that checklist in full. Treat it as the backstop for cases where the default did not hold, not as the primary line of defense: a team that only reviews and never fixes the defaults keeps catching the same mistake in every new feature. TLM Forge's red-team gate exists to be that backstop, blocking a ship until critical findings hit zero, the same discipline behind explicit guardrails on any code path that touches input the project does not fully control.

Conventions like these only hold up if they survive past a single session. A model told last week which helper wraps parameterized queries has no memory of that rule today unless something carries it forward, which is part of why an insecure shortcut fixed in one file can reappear in a new one weeks later. A private, persistent memory layer like MemX is one way to keep project conventions, including which secure primitives to default to, available across sessions instead of restated from scratch each time.

Frequently asked questions

01Does shifting the default mean the assistant will never write insecure code again?

No. It lowers how often an insecure pattern appears in the first draft, especially for common risk classes covered by pre-approved helpers and explicit rules. It does not cover every case, which is why a dedicated adversarial review pass still has to run before anything ships.

02Is this only about SQL injection?

No. The same pattern-completion problem shows up anywhere the model has seen both a safe and an unsafe version of a common operation: query construction, shell commands, authorization checks, password hashing, and secret handling all show the same asymmetry, and each benefits from the same fix, a pre-selected safe primitive plus an explicit rule naming it.

03Do project-level rules, like a CLAUDE.md file, actually change model behavior, or is this just documentation?

A named rule tied to a specific function is more effective than a generic instruction to write secure code, because it gives the model a concrete target to complete instead of an abstract standard to interpret. It is not a guarantee, which is why review remains the backstop, but a specific rule measurably narrows the outputs the model is likely to produce.

04Where does secure-defaults work fit relative to a security review checklist?

Before it, not instead of it. Secure defaults reduce how often a vulnerability class shows up in the first draft; a review checklist run against the finished diff catches what a shifted default still misses. One changes what the model reaches for, the other checks what actually shipped.

Ship AI-written code you can trust

TLM Forge is the missing process layer for Claude Code: a spec audit, independent multi-agent review, enforced TDD, and an adversarial red-team gate.

Get TLM Forge