The first time I really understood web security, it was not from a textbook. It was from watching a password reset flow behave in a way nobody intended. The application sent a reset token by email — a long random string in a URL. When I clicked the link and set a new password, I noticed the token was still in the browser's address bar. Out of curiosity, I opened the same link in an incognito window. The token still worked. The server had never invalidated it after use. Anyone who had seen my screen, intercepted the email, or found the URL in a browser history could have reset my password at any point after I had already used the link.
That was not a vulnerability caused by bad code. The code worked exactly as written. It was a vulnerability caused by a design assumption nobody had questioned: that tokens only need to exist until they are used, and that "used" is self-evident to the server. The developer had never asked: should I invalidate this after it is redeemed? Web security is built almost entirely from questions like that — the ones that get skipped when nobody is looking for them.
Web security (also called application security or AppSec) is the practice of designing, building, and operating web applications in a way that protects them and their users from unauthorized access, data exposure, and abuse. It is distinct from network security (firewalls, intrusion detection) and infrastructure security (server hardening, cloud IAM), though all three overlap in practice.
The most widely used framework for thinking about web application vulnerabilities is the OWASP Top 10 — a regularly updated list of the ten most critical application security risks, based on real-world prevalence data from security firms and bug bounty programs. Understanding the Top 10 is not the ceiling of web security knowledge. It is the floor. Every professional working on web applications should know these categories not as names to recite, but as patterns to recognize in real code.
The OWASP Top 10: A Real Breakdown
Rather than listing these as abstractions, I want to explain each one in the terms I actually think about them during code review and testing.
Injection (A03)
SQL injection is the most famous, but injection covers any situation where untrusted data is sent to an interpreter — SQL, OS shell, LDAP, NoSQL, XML parsers. The root cause is always the same: user input is mixed into a command string without being separated from it. The fix is always the same: use parameterized queries, prepared statements, or safe APIs that never allow user data to be interpreted as command syntax. An attacker who can inject SQL into a login query can bypass authentication, dump the entire database, or sometimes execute system commands.
Broken Authentication (A07)
This covers everything that goes wrong with login, session management, and credential handling: weak passwords accepted, passwords stored in plaintext or as MD5 hashes, session tokens that do not expire, tokens that are predictable, tokens that are not invalidated on logout, and password reset flows that can be manipulated. The reset token story from my opening is a broken authentication vulnerability. So is an application that generates session IDs sequentially.
Broken Access Control (A01)
Access control flaws are the most common vulnerability class in modern web applications. They exist when the application fails to enforce what authenticated users are allowed to do. IDOR (Insecure Direct Object Reference) is the most prevalent form: a user can access /api/invoices/9871 and then simply change the number to /api/invoices/9872 to access another user's invoice, because the backend never checks whether the requesting user owns that record. Vertical privilege escalation — a regular user accessing admin functions — is another form. These vulnerabilities are not found by automated scanners; they require understanding the application's intended authorization model and testing every endpoint against it.
Cross-Site Scripting — XSS (A03)
XSS happens when an application includes unvalidated user input in an HTML page without encoding it, allowing an attacker to inject JavaScript that runs in other users' browsers. Stored XSS — where the malicious script is saved to the database and served to every visitor — is the most severe form. It can steal session cookies, perform actions on behalf of victims, redirect users to phishing pages, or mine cryptocurrency in the victim's browser. The fix is consistent output encoding: anything that came from a user or an external source must be HTML-encoded before being placed in a response.
Security Misconfiguration (A05)
This is the category that never runs out of examples. Default credentials left in place. S3 buckets set to public. Debug mode enabled in production, exposing stack traces and environment variables. CORS configured with a wildcard * origin. Error pages that return full SQL queries to the client. Directory listing enabled on a web server. Unused features — admin interfaces, old APIs, sample applications — left deployed. Security misconfiguration is the vulnerability class where the fix is not a code change but a configuration change, which is why it is so common: developers are careful about code, but configuration is often an afterthought.
Security misconfiguration is consistently ranked #1 or #2 in real-world breach data. It is not glamorous, but it is responsible for more data exposures than any sophisticated exploit. Run your staging environment config through a checklist before every production deployment.
Input Validation as the Foundation
If there is one principle that prevents more vulnerabilities than any other, it is this: treat all data from the client as untrusted until you have validated it on the server. Not validated in JavaScript. Not validated in the browser. On the server, where the client cannot interfere with the validation logic.
Client-side validation is useful for user experience — it gives immediate feedback without a round trip to the server. But it provides zero security. Any user can open their browser's DevTools, modify or delete the JavaScript, and submit any data they want. Any user can use a proxy like Burp Suite to intercept and modify the HTTP request before it leaves the browser. The server has no way to know whether your validation code ran on the client. The server only sees the raw HTTP request it receives.
Server-side input validation means checking every incoming value against explicit criteria: the expected data type (string, integer, boolean), the expected length range, the expected format (UUID, email address, ISO date), and the expected value domain (a quantity must be a positive integer, a country code must be one of a known set). Values that fail any of these checks should be rejected with a clear error response before they touch any business logic or database query.
import { z } from 'zod';
// Define the schema — what we expect
const TransferSchema = z.object({
toAccountId: z.string().uuid(),
amount: z.number().positive().max(10000),
currency: z.enum(['USD', 'EUR', 'GBP'])
});
// In the route handler — validate before touching anything
app.post('/api/transfer', authenticate, (req, res) => {
const result = TransferSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid request', details: result.error.flatten() });
}
// result.data is now type-safe and validated
processTransfer(result.data);
});
Authentication vs Authorization
These two terms are frequently confused, and the confusion leads to real vulnerabilities. Authentication answers the question: who are you? It is the process of verifying that a user is who they claim to be — typically by checking a password, a token, or a biometric factor. Authorization answers the question: what are you allowed to do? It is the process of checking whether the authenticated identity has permission to perform the requested action.
A common mistake is to check authentication without checking authorization. An application might correctly verify that a request includes a valid session cookie (authentication passed) but then allow any authenticated user to read any record in the database (authorization missing). This is why IDOR vulnerabilities can exist even in applications with perfectly functional login systems.
The correct model is to check both for every protected action. Authentication at the middleware level ensures every request to protected routes has a valid identity. Authorization at the service layer ensures the identity has permission for the specific resource they are accessing. These checks must happen on the server. Role or permission data stored in a JWT payload can be trusted if the JWT is properly signed — but should still be validated against a server-side source of truth for high-stakes actions.
Transport Security
Every web application should serve all traffic exclusively over HTTPS. HTTP sends data in plaintext — credentials, session tokens, personal information — readable by anyone on the same network. HTTPS encrypts the connection using TLS (Transport Layer Security), which protects against eavesdropping and man-in-the-middle attacks.
Beyond just enabling HTTPS, there are several HTTP headers that strengthen transport security. Strict-Transport-Security (HSTS) tells browsers to always use HTTPS for your domain, even if the user types http:// — and to remember this for however many seconds the max-age directive specifies. Secure on cookies ensures they are never sent over HTTP, preventing cookie theft on mixed-content pages. These are not difficult to configure, and the combination of HTTPS plus HSTS plus Secure cookies closes the entire class of transport-level credential interception attacks.
Security Headers That Actually Matter
HTTP response headers can instruct the browser to enforce security policies that protect your users, even if your application has a vulnerability.
Content-Security-Policy (CSP) is the most powerful. It tells the browser exactly which origins are allowed to load scripts, styles, images, and other resources. A strict CSP that blocks inline scripts and only allows scripts from your own domain makes XSS dramatically harder to exploit, because even if an attacker injects a <script> tag, the browser will refuse to execute it. X-Content-Type-Options: nosniff prevents browsers from guessing a file's content type, which blocks a class of attacks where a malicious file uploaded as an image is executed as JavaScript. X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none' prevents your pages from being loaded inside iframes on other domains, blocking clickjacking attacks.
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"]
}
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true
}
}));
Logging and Monitoring
OWASP lists insufficient logging and monitoring as a Top 10 category for a reason: most breaches are not detected by the organization that was breached. They are discovered weeks or months later by a third party. The window between compromise and detection is when attackers exfiltrate data, establish persistence, and pivot to other systems. Logging does not prevent attacks, but it is the only mechanism that lets you detect them, understand their scope, and respond before the damage compounds.
What should you log? Authentication events — logins, failed logins, password resets, token generation. Access control failures — requests that were rejected because the user lacked permission. Input validation failures — requests that contained unexpected or malicious input patterns. Admin actions — anything that modifies users, permissions, or system configuration. High-value business events — large transactions, bulk data exports, account deletions.
What should you alert on? Multiple failed login attempts against the same account (credential stuffing). Many failed login attempts from the same IP (brute force). Access control failures from an authenticated session (someone probing for IDOR). Unexpected bulk data reads (data exfiltration in progress). The logs are only as useful as the alerting that catches patterns in them.
Secure SDLC: Shifting Left
"Shifting left" means moving security activities earlier in the development lifecycle — into design and development, rather than treating them as a gate at the end. The cost of fixing a vulnerability discovered in design is close to zero. Discovered in development, it costs a day of work. Discovered in QA, it costs a sprint. Discovered in production after a breach, it costs everything.
In practice, shifting left means: threat modeling during design (asking "how could an attacker abuse this feature before we build it"), security requirements included in every story (not just functional requirements), code review that includes security considerations (not just style and logic), automated scanning in CI/CD (SAST tools checking for common patterns like SQL concatenation and hardcoded secrets), and dependency auditing on every build (catching known-vulnerable packages before they ship). None of these require a dedicated security team — they require the development team to build security into their workflow the same way they build in testing.
Web Security Checklist
- All traffic served over HTTPS with HSTS enabled; no mixed-content pages
- All user input validated server-side for type, length, format, and range before any processing
- All database queries use parameterized statements or prepared statements — no string concatenation
- All output that was user-supplied is HTML-encoded before insertion into responses
- Authentication checks at route/middleware level; authorization checks at resource level for every protected action
- Session tokens are random, have a defined expiry, are invalidated on logout, and regenerated after privilege escalation
- Cookies use HttpOnly, Secure, and SameSite=Strict flags where possible
- Security headers set: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy
- Errors logged server-side; generic messages returned to clients — no stack traces or database errors in responses
- Dependencies audited regularly; known-vulnerable packages flagged and updated promptly
