Eqii
Security Intermediate 13 min read

Secure Coding Practices Every Developer Should Follow

OWASP Top 10 in practical terms: injection, broken auth, XSS, CSRF, SSRF, and insecure deserialization. Plus the principles — input validation, output encoding, least privilege, secrets management — that hold them all together.

The threat model comes first

Security without a threat model is theatre. Before you add a library, a header, or a check, you should be able to answer four questions: who might attack this system, what are they trying to do, what would it cost them, and what would it cost you if they succeeded. A public-facing marketing site with no login has a different threat model than a banking API, and the controls you apply should reflect that difference. The OWASP Top 10 is a checklist of common mistakes, not a security strategy. Read it, internalize it, then design defenses that match your actual risks.

The single most useful mental model is the attacker who is smart, motivated, and reading your source code. Anything you rely on being secret in client-side JavaScript is not secret. Anything you assume the user cannot reach because there is no UI for it can be reached. Anything you put in a URL or a hidden form field is data the user can edit. Build every system as if the client were hostile, because sometimes it will be.

A useful exercise is to write down three realistic attack scenarios for any feature you build. Who would attack it, how, and what would they gain? If you cannot think of a scenario, you are not looking hard enough. If you can think of one and the cost of defense is small, defend. If you can think of one and the cost of defense is large, document the decision and revisit it when the threat changes.

Injection: the vulnerability that refuses to die

Injection has been at or near the top of the OWASP list since the list began, and it is still the most common cause of catastrophic breaches. The classic form is SQL injection: a developer concatenates user input into a query string, an attacker supplies input that breaks out of the data context and into the query context, and suddenly they can read every table or drop the database.

The defense is straightforward and absolute: never build queries by string concatenation. Use parameterized queries everywhere. Every database driver you would seriously use supports them. In Node, the `pg` library accepts `client.query('SELECT * FROM users WHERE email = $1', [email])` and the parameter is sent separately from the query text, so the database treats it as data, never as SQL. The same pattern applies to MySQL, SQLite, Mongo, and every other engine. There is no good reason to write a query by string concatenation in 2025.

The same problem appears beyond SQL. NoSQL injection happens when you accept an object from user input and pass it straight to a query builder that interprets operators like `$where` or `$ne`. Command injection happens when you shell out using `child_process.exec` and pass unsanitized input. LDAP injection, XPath injection, and template injection all follow the same shape: user input crosses from a data context into an execution context. The fix is always the same — use the API designed to keep contexts separate, and never build executable strings from user data.

One injection vector that is easy to miss is the template engine. Server-side template engines like Jinja2, Twig, and Handlebars will happily render templates from user input if you let them, and a template that calls into arbitrary functions is a remote code execution waiting to happen. The rule: never render user-supplied templates. Render server-controlled templates with user-supplied data, and you are safe.

Authentication and session management

Broken authentication is the second item on the OWASP list, and it covers three distinct failure modes: weak passwords, credential stuffing, and broken session management.

Weak passwords are solved by length, not by complexity rules that frustrate users. NIST 800-63B, the current authoritative guidance, recommends a minimum of eight characters, screening against a list of known-breached passwords (the Have I Been Pwned API or an equivalent breached-password dataset), and no mandatory composition rules. Forcing users to add a symbol and a number does not meaningfully improve entropy; forcing them to use a password manager and a long passphrase does.

Credential stuffing — attackers trying username and password pairs stolen from one breach against another site — is defeated by rate limiting, multi-factor authentication, and breach detection. SMS-based second factors are weaker than they look because of SIM swap attacks; TOTP apps (1Password, Authy, Google Authenticator) are stronger; FIDO2 hardware keys (YubiKey, Titan) are stronger still. Any service holding sensitive data should offer WebAuthn.

Session management has several specific traps. Session IDs must be cryptographically random, sufficiently long (at least 128 bits), and rotated after login. Cookies that carry session IDs must set the `Secure`, `HttpOnly`, and `SameSite=Lax` (or `Strict`) attributes. The `Secure` flag prevents the cookie from being sent over plain HTTP. The `HttpOnly` flag prevents JavaScript from reading it, which closes the most common XSS-to-session-theft path. `SameSite=Lax` blocks the cookie from being sent on most cross-site requests, which defeats the simplest CSRF attacks. Set all three. Always.

After login, rotate the session ID. Otherwise an attacker who observed a pre-login session can hijack the post-login session, a problem known as session fixation. Most frameworks handle this for you, but verify it: write a test that logs in and checks that the session cookie value changed.

XSS, CSRF, and the browser trust boundary

Cross-site scripting (XSS) is the vulnerability that lets an attacker run their JavaScript in the context of your page. Once they do, they can read the DOM, steal cookies that lack `HttpOnly`, make authenticated requests to your API, and exfiltrate data. XSS comes in three flavors: stored (the malicious payload is persisted in your database and served to other users), reflected (the payload is bounced off a URL parameter), and DOM-based (the payload never reaches the server; the vulnerability is in client-side code that reads `location.hash` and writes it into the DOM).

The fundamental defense is output encoding. Whenever you insert data into HTML, encode it for the HTML context. If you are inserting into an attribute, encode for the attribute context. If you are inserting into a URL, encode for the URL context. If you are inserting into JavaScript, encode for the JavaScript context — and ideally, do not do that at all. Modern frameworks handle this for you: React escapes by default, Vue escapes by default, Angular escapes by default. The danger appears when you use `dangerouslySetInnerHTML`, `v-html`, or `innerHTML` directly. Use those APIs only with content you have explicitly sanitized with a library like DOMPurify.

Content Security Policy (CSP) is the second line of defense. A CSP header tells the browser which sources of script, style, image, and other resource are allowed. A strict CSP that disallows inline scripts and restricts script sources to your own origin closes most XSS vectors even if a vulnerability exists in your code. Setting `script-src 'self'` plus a per-request nonce is the modern recommendation from the CSP specification authors.

Cross-site request forgery (CSRF) is the inverse attack: an attacker's site causes the victim's browser to make an authenticated request to your site, and the victim's session cookie is sent along automatically. The classic defense is a CSRF token embedded in the form and verified on the server. The modern defense, which is simpler and equally effective for most cases, is the `SameSite=Lax` cookie attribute, combined with checking that requests that change state use `POST`, `PUT`, `PATCH`, or `DELETE` rather than `GET`. If your cookies are `SameSite=Strict` and your API checks the `Origin` header on state-changing requests, CSRF is largely solved.

SSRF and insecure deserialization

Server-side request forgery (SSRF) happens when your server accepts a URL from user input and fetches it. The attacker supplies a URL that points at your internal network — `http://169.254.169.254/latest/meta-data/` on AWS, for example, which returns the EC2 instance metadata including any IAM credentials — and your server happily fetches it and returns the response. The Capital One breach in 2019 was an SSRF vulnerability that exposed 100 million credit card applications.

The defense is to validate the destination before fetching. Resolve the hostname, check that the IP address is not in a private range (RFC 1918: 10.x, 172.16-31.x, 192.168.x), the loopback range (127.x), the link-local range (169.254.x), or cloud metadata ranges, and then connect to the IP directly to prevent DNS rebinding. If you must allow users to fetch arbitrary URLs, run the fetcher in a sandboxed network with no access to internal services.

Insecure deserialization is the cousin of injection. It happens when a server calls `unserialize()` on attacker-controlled data, and the deserialization process instantiates arbitrary classes whose constructors or `__wakeup` methods have side effects. PHP, Java, Python's pickle, and .NET have all shipped historic deserialization gadgets that allowed remote code execution. The fix is to never deserialize untrusted data using a format that can instantiate arbitrary objects. Use JSON, which has no execution semantics, for any data crossing a trust boundary. If you absolutely must use a binary format, sign it with a HMAC and verify the signature before deserializing.

Input validation, output encoding, and least privilege

Three principles run through every section above. State them explicitly so they become habits.

First, validate input on the server. Client-side validation is for user experience; server-side validation is for security. Validate type, length, range, and format. Use a schema validation library — Zod, Valibot, Joi, Pydantic, depending on your language — and run every request body, query parameter, and path parameter through it before your code touches it. Reject anything that does not match. Never silently coerce.

Second, encode output for the context you are writing into. The same data may need different encoding in HTML, in an attribute, in a URL, in JavaScript, in a SQL query, and in a shell command. There is no universal sanitize function. There is only the right encoding for the right sink, applied every time.

Third, run with the least privilege that lets the code do its job. A web server does not need root. A database user that only reads does not need write permissions. A worker process that sends email does not need access to the user table. Compromise of one component should not automatically compromise everything else. Segment your services, your database users, and your cloud IAM roles so that the blast radius of any single breach is small.

A fourth principle worth stating: fail closed, not open. If your auth service is down, the safe behavior is to deny access, not to allow it because the check could not run. The same applies to rate limiters, IP allowlists, and feature flags that gate sensitive operations. Defaulting to denial under failure is uncomfortable in the short term and correct in the long term.

Secrets, dependencies, and the supply chain

Secrets — API keys, database passwords, signing keys — are the crown jewels of your application, and they leak in three predictable ways: committed to source control, logged to plaintext log files, and exposed through misconfigured environment variables on the client.

Committing a secret to Git is the most common and the most preventable. Use a `.gitignore` that excludes `.env` files. Run a pre-commit scanner like `gitleaks` or `trufflehog` that catches secrets before they reach the remote. If a secret does get committed, rotate it immediately — assume it is already in an attacker's collection. GitHub's secret scanning catches known formats automatically, but it is reactive, not preventive.

Dependencies are a supply chain problem. Every npm package you install runs code at install time, in your CI pipeline, and in your production bundle. The left-pad incident in 2016 and the event-stream compromise in 2018 are old news; supply chain attacks continue. Run `npm audit` or `pnpm audit` regularly and act on the results. Pin your dependencies to specific versions through the lockfile. Consider a tool like Snyk, Dependabot, or OSV-Scanner that flags known vulnerabilities in your dependency tree. For high-security environments, look at Sigstore and the SLSA framework, which verify that the package you received was actually built by the legitimate maintainer.

A practical habit: review your dependency list quarterly. Every package you remove is a package you no longer need to track for vulnerabilities. The cost of carrying an unused dependency is small per package and large in aggregate; the cost of carrying a vulnerable one can be catastrophic.

Transport security and HTTP security headers

HTTPS is no longer optional. Let's Encrypt has made TLS certificates free and automatic, and every major hosting platform terminates TLS at the edge. There is no excuse for serving anything over plain HTTP in 2025. The one mistake to avoid: serving a redirect from HTTP to HTTPS without also setting the `Strict-Transport-Security` header. The header tells the browser to always use HTTPS for your origin, which closes the small window where a man-in-the-middle could intercept the redirect. A reasonable starting value is `Strict-Transport-Security: max-age=31536000; includeSubDomains`.

A small set of additional headers closes most remaining gaps:

  • `Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<random>'` — restricts resource loading and is the strongest XSS mitigation available.
  • `X-Frame-Options: DENY` (or the CSP equivalent `frame-ancestors 'none'`) — prevents your page from being embedded in an iframe, which closes clickjacking attacks.
  • `X-Content-Type-Options: nosniff` — stops the browser from guessing MIME types, which prevents some content-sniffing attacks.
  • `Referrer-Policy: strict-origin-when-cross-origin` — limits how much referrer information leaks to third parties.
  • `Permissions-Policy` — lets you disable browser features (camera, microphone, geolocation) you do not use.

Security is not a single decision; it is a thousand small habits. None of these headers, none of these validations, none of these cookie flags will save you on their own. Stacked together, they make the attacker's job hard enough that they will likely move on to an easier target. That is the practical goal.