Secure Coding
Most vulnerabilities are not clever. They are ordinary code written the ordinary way, by someone who was thinking about the feature and not about the input. The fix almost always costs less at write time than at any point afterwards — a parameterized query is not harder to write than a concatenated one, it just has to occur to you first.
This skill exists to make it occur to you first. It covers three things that account for the overwhelming majority of findings in real codebases:
- Dependencies — not pulling in a library that arrives already vulnerable or abandoned.
- Code patterns — writing the shape that static analysis (and attackers) won't flag.
- Secrets — never in the source, always from the environment, failing loudly when absent.
It is about writing code. Deployment, pipelines, and platform configuration are out of scope.
How to use this
Apply the transversal rules below — they hold in every language. Then read the reference for the stack you are writing in, before writing the code, not after:
references/python.md— FastAPI, SQLAlchemy, subprocess, file handling, pydantic-settingsreferences/go.md— database/sql, os/exec, net/http clients, filepath, env configreferences/react.md— rendering untrusted values, tokens in the browser, what never belongs in a bundlereferences/angular.md— template binding, sanitization, HTTP interceptors, route guardsreferences/dependencies.md— how to decide whether a library is safe to add
The references are short and pattern-oriented. Read the one you need in full; skimming for the code block loses the reasoning, and the reasoning is what transfers to the case that isn't listed.
The transversal rules
1. Untrusted input is anything you did not write
Not just "what the user types". Query parameters, path segments, headers, cookies, request bodies, uploaded filenames, webhook payloads, rows read back from a database someone else writes to, responses from third-party APIs, environment values in multi-tenant contexts, and message-queue payloads are all input you did not write.
The question to ask at every boundary is not "is this user input?" but "where does this value end up?" A value is dangerous in proportion to the sink it reaches:
| If the value reaches… | The risk | What to do instead |
|---|---|---|
| A SQL query | Injection | Bind it as a parameter; never build the query by concatenation or interpolation |
| A shell command | Command injection | Pass an argument vector; never build a shell string |
| A filesystem path | Path traversal | Resolve and confirm it stays under an allowed base |
| An HTTP client's URL | SSRF | Match against an allowlist; never let the value form the host |
| HTML output | XSS | Let the framework escape it; do not reach for the "raw"/"unsafe" API |
| A deserializer | Remote code execution | Use a data format (JSON), not one that instantiates objects |
eval, a template compiler, a module path |
Code injection | Map the value to a function through an explicit allowlist |
The most reliable habit is to narrow the type as early as possible. A value that should be an integer becomes an integer at the boundary; one that should be one of four options becomes an enum. Validation that runs at the edge protects every sink downstream at once, and it is far easier to review than a sanitizer buried three calls deep.
2. Prefer the construct that cannot be misused
Given two ways to do something, choose the one where the unsafe version is not expressible. A parameterized query API takes the values separately, so there is no way to accidentally concatenate. An argv-style exec call takes a list, so there is no shell to inject into. A template engine that escapes by default is safe unless someone explicitly opts out.
This matters more than remembering to sanitize, because sanitizing is a thing you can forget and an API shape is not. When you find yourself writing careful escaping logic by hand, stop and check whether the library already offers the safe construct — it almost always does, and hand-rolled escaping is a classic source of bypasses.
3. Secrets come from the environment and fail loudly
No credential, token, API key, connection string, or signing key belongs in source code — not in a constant, not in a default argument, not in a config file that gets committed, not in a test fixture that later gets copied into production code.
Read them from environment variables, and fail at startup when one is missing. The tempting shortcut is a fallback:
api_key = get_env("SERVICE_API_KEY") or "" # do not
api_key = get_env("SERVICE_API_KEY") or "default" # worse
Both convert a configuration error into a silent runtime failure. With an empty string the service starts happily and fails later somewhere unrelated, usually in production, usually with an error that points at the wrong thing. Crashing at startup is the kind behavior: it fails where the problem is, before serving a single request.
Two habits that go with this:
- Add the variable name to
.env.example(no value) when the repo has one, so the next person knows the variable exists. - Never log the value, not even at debug level, not even truncated. Log that it is set, never what it is.
4. Fail closed on authorization
When a check cannot be completed — the user record is missing, the token is malformed, the permission service is unreachable — the answer is deny. Code that treats "I could not determine" as "allow" is how authorization bypasses happen.
Two related habits: check permissions on the server for every request that reads or changes something (hiding a button is presentation, not authorization), and check that the authenticated user owns this specific record, not merely that they are logged in. Fetching by ID without an ownership condition is the most common broken-access-control bug there is.
5. Errors inform the caller, logs inform you
A stack trace returned to the caller hands over file paths, library versions, and query structure. Return something generic plus an identifier; log the detail server-side and correlate by that identifier.
Be equally careful about what goes into logs: credentials, tokens, full request bodies of authentication calls, and personal data all leak through logs routinely, and logs get shipped to places with much broader access than the database.
6. Use the boring, current cryptography
Hash passwords with a password hashing function designed for it — bcrypt, scrypt, or argon2id — through the framework's helper. A general-purpose hash like SHA-256 is not a password hash regardless of salting, because it is fast, and fast is the wrong property here.
For everything else: SHA-256 or better for integrity, AES-GCM or ChaCha20-Poly1305 for encryption (authenticated modes, fresh nonce per message), and the platform's cryptographic random source for anything that must be unguessable — tokens, session identifiers, password reset links, nonces. The convenient random() in every standard library is predictable by design and belongs only in code where nothing depends on it being unguessable.
Do not design your own scheme or implement a primitive by hand.
Adding a dependency
A vulnerable dependency is a vulnerability you wrote, even though you did not write the code. Before adding a library, spend the two minutes: check that the version you are about to install has no known advisories, that the project is actually maintained, and that you need it at all.
The full procedure — where to check, how to read an advisory, what "maintained" means concretely, and the deprecation trap — is in references/dependencies.md. Read it before adding anything you have not vetted before.
The short version: the newest version is not automatically safe, and a popular library is not automatically maintained. Widely-used packages get deprecated and archived while tutorials keep recommending them for years afterward.
Before you call it done
Reread what you wrote, looking specifically for the failure shapes above. This takes a minute and catches the majority of what a scanner would flag later:
- Every value that came from outside — did you follow it to where it lands? Any string built by concatenation or interpolation that becomes a query, command, path, or URL?
- Any credential, key, token, or connection string sitting in the source? Any
""or"default"fallback for a missing environment variable? - Does every endpoint that reads or changes data check both authentication and ownership of this record?
- Do error responses leak internals? Do logs contain secrets or personal data?
- Any new dependency vetted for advisories and maintenance?
- Anything unguessable generated with a non-cryptographic random source?
When you report the work, say what you actually verified rather than asserting the code is secure. "Query is parameterized, key read from SERVICE_API_KEY with startup failure if absent, no new dependencies" is a useful claim a reviewer can check. "Implemented securely" is not.
If you notice something outside the scope of what was asked — a pre-existing issue in a neighbouring function — mention it rather than fixing it silently. Expanding the change without saying so makes the work harder to review, and the person who asked gets to decide whether it is worth doing now.
When the safe way conflicts with the request
Sometimes the requested design is the problem: a search endpoint that takes a raw SQL fragment, a file endpoint that takes an arbitrary path, a webhook that skips signature verification "for now".
Say so plainly in a sentence or two, propose the alternative that gets the same outcome, and — if the person still wants it their way — implement it, note the risk in a comment at the relevant line, and move on. It is their system and their call. What is not acceptable is implementing the unsafe version quietly, because then nobody ever decided anything.