Writing · 2026-08-29
Secure coding practices
A working checklist for input validation, SQL injection, authn/authz, least privilege, privacy, CSRF, exceptions, secrets, dependencies, encryption, deserialization, file uploads, and code review.
These are the defaults I expect in production Java / Spring Boot services (and the UIs that call them). They are written so a reviewer can check a PR against them. They are not a substitute for threat modeling on a new boundary.
Input validation
Treat every value that crossed a trust boundary as hostile: HTTP body and query, path variables, headers you did not set, uploaded names, message payloads, and model or user text you will store or render.
- Validate type, length, range, and format at the edge (
@Valid, Bean Validation, or an explicit schema). Reject unknown fields when the contract is closed. - Prefer allow-lists (UUID, ISO date, known enum,
[A-Za-z0-9_-]{1,64}) over “strip the bad characters.” - Cap collection sizes and string lengths. A 50 MB JSON “note” is a resource attack.
- Normalize then validate (Unicode, trim). Do not validate a string and then decode it again.
- Path and host: never build a filesystem path or redirect URL by concatenating raw input. Use a stored id and a server-side map to the real key.
- Output encode on the way out (HTML, JSON, CSV, logs). Validation does not make comments or chatbot text safe to drop into the DOM.
Fail closed: 400 with a generic message. Do not echo the rejected payload back to the client in production.
SQL injection
If user input can change the shape of a query, you have an injection path.
- Use parameterized queries only: Spring Data JPA,
PreparedStatement, named parameters. Never assemble SQL with+,String.format, or interceptors that splice strings intoWHERE. - Dynamic
ORDER BY/ column names come from an allow-list, not from the query string. - Same rule for JPQL, native queries, and anything sent to Mongo (
$where, unchecked JSON) or to a search DSL. - Least-privilege DB users: the request-path role cannot
DROP, and should not needDELETEon audit tables. - Hide raw SQL errors from clients (see exception handling).
A code review that sees "... WHERE id = '" + id + "'" is an automatic change request.
Authentication and authorization
Authentication answers who the caller is. Authorization answers whether they may do this to this resource. A valid JWT is not authorization.
Authentication:
- Validate signature, issuer, audience, and expiry on every request. Do not “decode the payload and trust it.”
- Short-lived access tokens; refresh is a separate, monitored path.
- Google / OKTA / OIDC / SAML at the edge; Spring Security in the service. Session fixation: new session id after login.
- Service-to-service traffic uses its own client identity, not a copied end-user token reused forever.
Authorization:
- Enforce RBAC (and data ownership) on the server. Hiding a button in Angular/React is not a control.
- Check the resource: caseworker role and this
caseId/fileId/ program. Horizontal privilege (id in the URL) is the usual miss. - Default deny. New endpoints inherit authenticated + explicit method security (
@PreAuthorize), not “open until we add security.” - Admin and break-glass paths are separate, audited, and not in the public API surface.
Tests I expect: expired token → 401, wrong audience → 401, valid user / wrong role → 403, valid role / other user’s id → 403.
Principle of least privilege
Give the process the smallest permission set that still ships the feature — in IAM, in the database, and in the code.
- Split roles the way you split runtimes: compute (ECS/EKS/EC2) may call APIs; it does not get
s3:GetObject/PutObjecton the documents bucket. Functions that presign or process objects have that access. - Application DB user: DML on the tables it owns, not superuser, not
CREATE. - Kafka/SQS: consume only the queues the service owns; no account-wide
*. - Humans: production credentials are not on laptops; break-glass is time-boxed.
- Libraries and AI tools: the model does not receive AWS keys, DSNs, or a full case record. MCP tools call APIs that already enforced RBAC.
If a permission was added “so we could debug,” remove it in the same sprint or you have changed the architecture.
Prevent privacy data exposure
PII and case data leak through logs, errors, URLs, and extra fields more often than through a dramatic breach.
- Collect the minimum. Do not store SSN, full PAN, or raw health notes if a token or last-four will do.
- Do not log tokens, passwords, presigned URLs, authorization headers, request bodies with PII, or prompt contents that include applicant data. Log
correlation_id,file_id, status, latency. - Do not put secrets or PII in query strings (they land in access logs and Referer).
- API responses: return only fields the caller needs. No internal ids, stack traces, or “debug” objects in production.
- Mask in support tools and Splunk. Retention and access on log indexes are part of the design.
- Frontend: do not persist case payloads in
localStorageunless you have a written reason and an expiry.
Secure handling (data in use and in transit)
- TLS everywhere that leaves the box (HTTPS, Kafka SSL, DB TLS). No “internal HTTP is fine” for case data.
- Cookies:
Secure,HttpOnly,SameSiteappropriate to the app (see CSRF). - Cache:
Cache-Control: no-storeon authenticated responses that contain PII. - Memory: do not keep passwords or raw keys in
Stringlonger than needed; prefer clearing buffers for high-assurance secrets. - Multi-tenant or program data: tenancy / program is a predicate on every query, not a hope that the UI only sent the right id.
CSRF protection
Browsers send cookies on cross-site requests. If you use cookie sessions, you need CSRF defenses.
- Spring Security CSRF enabled for cookie-authenticated browser apps; token on mutating requests (
POST/PUT/PATCH/DELETE). SameSite=LaxorStricton session cookies reduces risk; it does not replace a CSRF token for all cases.- Bearer JWT in
Authorization(not automatically attached by the browser) is not a CSRF vector the way cookies are. Do not disable CSRF globally “because we have JWTs” if any cookie-authenticated route remains. - Change-of-state GETs are forbidden. Logout and deletes are POST/DELETE with the same authz checks.
Secure exception handling
Exceptions are a data leak if you are not deliberate.
- Catch at the boundary (
@ControllerAdvice). Map to a stable error code and a short public message. Never send stack traces, SQL, paths, or hostnames to the client in production. - Log the detail server-side with a correlation id the client may see, so support can find the event without the user pasting internals.
- Do not use exceptions for ordinary control flow on hot paths, and do not
catch (Exception e) { return 200; }. - Fail closed on authz and crypto errors (401/403), not 500 with a hint that the key “almost” worked.
- Avoid double-logging secrets that were on the exception message (JDBC URLs, token fragments).
Secrets management
- No secrets in git, images, frontend bundles, or ticket comments.
application.ymlholds names of secrets, not values. - Inject at runtime: AWS Secrets Manager, SSM, Kubernetes secrets from a vault, or the platform’s equivalent. Rotate by changing the secret store, not by hoping old pods died.
- Different secrets per environment. Production credentials never in local
.envthat gets committed. - CI: OIDC to the cloud where possible, not a long-lived access key on the Jenkins agent.
- Revoke and rotate on staff change or suspected leak. Treat a secret in Slack as leaked.
Dependency security
- Lock versions (lockfile / BOM). Do not take “latest” on a Friday in production.
- Scan every build (WhiteSource, Dependabot, Snyk, or equivalent). A CVE on a library you ship is a ticket with an owner, not a PDF in the release zip.
- Remove unused dependencies. Less surface, fewer surprises.
- Prefer well-maintained libraries for crypto and JWT; do not write your own parser.
- Container base images are dependencies too: rebuild, scan, and pin digests.
Encryption
- In transit: TLS 1.2+; disable weak ciphers by using the platform default you have already hardened.
- At rest: disk/volume encryption plus application-level encryption for high-sensitivity fields when the DBAas-a-service key is not enough.
- Keys live in a KMS/HSM-backed store. The app uses grants or IAM to decrypt, not a key file in the image.
- Do not invent algorithms. Use AES-GCM (or the platform envelope) and standard libraries.
- Passwords: hash with a slow algorithm (bcrypt/argon2) and a unique salt. Never encrypt passwords for “later decrypt.”
- Presigned URLs and tokens are capability secrets: short TTL, HTTPS only, not logged.
Safe deserialization
Deserialization is code execution if the format can name types.
- Prefer JSON with an explicit DTO and a closed schema. Do not bind
Object,Map<String, Object>of untrusted blobs, or YAML!!tags from users. - Java: never
ObjectInputStreamon untrusted bytes. Disable default typing in Jackson unless you have an allow-list of types and a written reason. - XML: disable external entities (XXE) on every parser. Do not use a “convenient” default that still resolves DTDs.
- Message payloads (Kafka/SQS): versioned Avro/JSON contracts; reject unknown type ids.
If you need to persist a blob, store it as bytes and parse with a dedicated, tested decoder — not readObject().
Secure file uploads
This matches the S3 pattern we use so the app server never holds the bytes.
- Authenticate and authorize before issuing an upload URL. The object key is server-generated (
file_id), not the user’s filename. - Allow-list content types and max size on the presign (and enforce again on the processor after
HeadObject). - Do not trust
Content-Typeor extension alone for execution risk; store outside the web root; serve via presigned GET after authz, not as static public objects. - Scan or at least reject active content (HTML/SVG/JS) if the file will ever be opened in a browser as a first-party document.
- Metadata in Postgres: owner, status (
pendinguntil the processor confirms), size, checksum./download-urland/delete-filecheck the same ownership as upload. - Virus/malware scanning belongs on the processor path when the threat model includes hostile documents.
Other habits that belong in the same bar
- XSS: encode on output; Content-Security-Policy on browser apps; no
innerHTMLwith API strings. - SSRF: do not fetch URLs the user supplied unless allow-listed; block link-local and cloud metadata hops.
- Redirects: allow-list of relative paths or known hosts.
- Rate limiting on login, upload-url, and comment/AI endpoints.
- Idempotency on payments and file completion so retries do not create a second grant.
Code review guidelines
Use this as the review script. If a box cannot be answered from the PR, ask or block.
Identity and access
- Who is the caller after this change? Which test proves expired / wrong-audience / missing-role / wrong-resource-id?
- Is authorization on the resource, or only “authenticated”?
- Did IAM, DB grants, or topics get wider? If yes, is that justified and documented?
Data and input
- Every new field: validated, length-capped, allow-listed if it affects SQL, paths, or headers?
- Any string-built query, native SQL, or dynamic filter? Must be parameterized or allow-listed.
- Any file, URL, or redirect the client influences?
Privacy and output
- New logs: can they contain tokens, PII, or presigned URLs? If yes, change them.
- Error responses: generic to the client, detail only server-side with a correlation id.
- API JSON: extra internal fields removed?
Secrets, crypto, dependencies
- New secret: where does it live, how does it rotate, is it in the diff?
- New crypto or JWT handling: standard library only?
- New dependency: why, locked version, license, and scan clean?
CSRF, cookies, sessions
- Cookie session changes: CSRF still on?
Secure/HttpOnly/SameSiteset? - Logout and state-changing GETs removed?
Deserialization and files
- New parsers: JSON DTO only, or a dangerous type?
- Uploads: server-side key, size/type limits, authz on download/delete, no execute-from-upload path?
Exceptions and resilience
- New catch blocks: do they swallow authz failures or return 200?
- Timeouts and retries on outbound calls: no retry storms, no leaking downstream errors.
AI / tools (when the PR touches them)
- Can the model see credentials, raw case dumps, or uncited policy?
- MCP/tool calls: same RBAC as the human API?
Review hygiene
- Prefer small PRs that include tests for the new deny paths.
- Do not approve “fix later” on authn, injection, or secret-in-repo.
- A scanner finding is a starting point, not a pass. Read the authz path yourself.
If the answers are “the JWT,” “admin,” “the app role,” “in the repo,” and “we log the body,” the change is not ready to merge.
Discussion
Comments from readers and recruiters.
No comments yet. Be the first to leave a note for Subose or for other readers.