Web Security Fundamentals Every Developer Should Know
The web security fundamentals every developer needs — threat modeling, the attacker's perspective, defense in depth, and the mindset shift that makes secure code second nature.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Web Security Fundamentals Every Developer Should Know
Security gets treated as a specialty discipline — something the "security team" handles, something you bolt on at the end of a project, something that requires a dedicated expert. This framing is why most web application vulnerabilities exist. The reality is that the vast majority of web security issues are preventable by application developers applying consistent, learnable practices. No security clearance required.
I have reviewed enough codebases to have a clear picture of where vulnerabilities come from. They come from developers who did not think about what an attacker would do with their code. The fix is not adding a security specialist to your team. The fix is changing how you think about the code you write.
Think Like an Attacker (Without Being One)
The most fundamental shift in security thinking is adopting an adversarial perspective on your own code. For every piece of functionality you build, ask: how would a malicious user try to abuse this?
For a login form: what happens if someone submits 10,000 login attempts with different passwords? What happens if someone submits a username that is 10 megabytes long? What happens if someone submits SQL in the username field?
For a file upload: what happens if someone uploads a PHP script instead of an image? What if they upload a valid JPEG with malicious JavaScript embedded in the EXIF data? What if they upload a 5GB file?
For an API endpoint that returns user data: what happens if a user changes the userId parameter to someone else's ID? What if they send a negative number? What if they send a string instead of an integer?
These are not exotic edge cases. They are the first things an attacker tries, and they regularly work on applications that were never tested with adversarial inputs.
The Principle of Least Privilege
Every component of your system — database users, application processes, API keys, IAM roles — should have access to exactly what it needs to perform its function, nothing more.
Your API's database user should have SELECT, INSERT, UPDATE, and DELETE on the specific tables it uses. It should not have DROP TABLE, CREATE USER, or access to system tables. If an attacker achieves SQL injection through your API, a properly restricted database user limits the damage significantly — they cannot delete all your tables or create backdoor accounts.
Your application process should run as a non-root user. Your container should not have the Docker socket mounted. Your S3 bucket for user avatars should have a policy that permits writes from your application and reads from anyone, but not permission to delete or create new buckets.
Least privilege is not paranoia. It is the difference between a security incident that is contained and one that is catastrophic.
Defense in Depth
No single security control is sufficient. Defense in depth means layering multiple controls so that bypassing one does not give an attacker everything.
Consider user-uploaded files. A single control approach: you check the file extension. Defense in depth: check the file extension, check the MIME type from the content-type header, read the file's magic bytes to validate its actual type, store uploaded files outside the web root so they cannot be executed as server-side scripts, scan files with an antivirus service, serve uploaded files from a separate domain or subdomain, set Content-Disposition: attachment so browsers download rather than execute them.
An attacker who bypasses your extension check — renaming a PHP file to avatar.jpg — still cannot execute it because the file is not in a web-accessible directory and the server is not configured to execute scripts from the upload directory.
Each control adds work for the attacker. Bypassing all of them is much harder than bypassing one.
Input Validation: Validate Everything
All user input is untrusted. This includes form fields, query parameters, URL path segments, HTTP headers, JSON request bodies, file contents, and cookies. Any data that comes from outside your application must be validated before you do anything with it.
Validation means: confirming the data is the expected type, confirming it is within acceptable length and format constraints, and rejecting anything that does not meet those constraints with a clear error.
import { z } from "zod";
const createUserSchema = z.object({
email: z.string().email().max(255),
username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_-]+$/),
age: z.number().int().min(13).max(120).optional(),
});
function validateCreateUser(input: unknown) {
const result = createUserSchema.safeParse(input);
if (!result.success) {
throw new ValidationError(result.error.flatten());
}
return result.data;
}
Validation is not sanitization. Sanitization is transforming input (removing HTML tags, escaping special characters). Validation is checking whether input meets your requirements and rejecting it if not. Both have their place — prefer validation for most cases, and use sanitization carefully with a clear understanding of what it does and does not protect against.
Output Encoding
Just as all input is untrusted, all output that includes untrusted data must be encoded for the context it is being placed into.
Data placed into HTML must have HTML special characters encoded (& becomes &, < becomes <, " becomes ").
Data placed into a JavaScript string context must have JavaScript special characters escaped.
Data placed into a SQL query must use parameterized queries — never string concatenation.
Data placed into an OS command must be escaped for shell interpretation — or better, never put untrusted data into shell commands at all.
The context matters. HTML encoding does not protect you in a JavaScript context. SQL escaping does not protect you in a shell context. Use context-appropriate encoding.
Modern frameworks handle most of this automatically. React escapes output by default. Prisma uses parameterized queries. The dangerous paths are when you bypass these defaults: dangerouslySetInnerHTML in React, raw query execution in your ORM, exec() in Node.js.
Authentication and Sessions: The Basics
Authentication is identifying who a user is. Authorization is determining what they can do. Conflating them is a common source of security vulnerabilities.
For authentication: use a battle-tested library rather than building your own. Password hashing should use bcrypt, Argon2, or scrypt — not SHA-256, MD5, or any fast hashing algorithm. Session tokens should be generated with a cryptographically secure random number generator, not sequential IDs or guessable values.
For sessions: store session data server-side (or in a signed, encrypted cookie). Session tokens should be long (128 bits of entropy minimum), expire after a reasonable period, and be invalidated on logout. Transmit session tokens only over HTTPS.
For authorization: check permissions for every request, not just at the route level. An authorization check at the route level that passes a user ID through to a database query without verifying that user ID belongs to the authenticated user is a broken access control vulnerability.
Security Headers
HTTP security headers tell browsers how to handle your content and provide a line of defense against several classes of attacks. They cost nothing to add and provide real protection:
Content-Security-Policy— controls which resources the browser can load (prevents XSS)X-Frame-Options: DENY— prevents your pages from being embedded in iframes (prevents clickjacking)X-Content-Type-Options: nosniff— prevents browsers from MIME-sniffing responsesStrict-Transport-Security— forces HTTPS connectionsReferrer-Policy— controls how much information is sent in the Referrer headerPermissions-Policy— disables browser features your site does not use
Add these to every response. Most web frameworks and server configurations make this straightforward with a middleware or config block.
Error Messages and Information Leakage
Production error messages should not include stack traces, database query details, internal file paths, or any other information about your implementation. These details are useful for debugging — and equally useful for attackers mapping your system.
Show users a generic error message. Log the detailed error server-side where only you can see it. The user gets "Something went wrong, please try again." Your logs get the full stack trace, query details, and request context.
This applies to authentication error messages too. "Incorrect password" confirms to an attacker that the username exists. "Invalid credentials" does not. This is a minor security improvement — usability often warrants being more specific — but it is worth knowing about.
The Security Mindset
Security is not a checklist you complete. It is a perspective you maintain throughout development. Every time you add a new feature, ask the adversarial questions. Every time you handle user data, apply least privilege and defense in depth. Every time you process input, validate it. Every time you generate output, encode it correctly.
These habits take effort to build and become second nature over time. The developers who write the most secure code are not necessarily the most technically sophisticated — they are the ones who have internalized the adversarial perspective.
If you want a security review of your application or help building security practices into your development process, book a session at https://calendly.com/jamesrossjr.