Web Security Fundamentals: CIA Triad & Threat Modeling

Welcome to Phase 1 of the Web Security Learning Roadmap! This post covers the foundational security concepts that every developer must understand before writing a single line of production code.
Security isn't about memorizing a list of vulnerabilities — it's about developing a security mindset. These fundamentals give you the mental models to reason about security in any situation, across any language or framework.
By the end of this post, you'll have the conceptual toolkit to think like a security engineer: identifying what needs protection, what threatens it, and how to build systems that defend themselves at multiple layers.
What You'll Learn
✅ Understand the CIA Triad and apply it to real application decisions
✅ Perform threat modeling using the STRIDE framework
✅ Map attack surfaces in your own applications
✅ Design systems with defense in depth
✅ Apply the principle of least privilege
✅ Understand zero trust architecture basics
✅ Build security requirements into your development process
The CIA Triad: The Foundation of Security
Every security decision ultimately comes back to three properties. Together they form the CIA Triad — the bedrock of information security.
Confidentiality
Confidentiality means protecting data from unauthorized access. Only the right people should be able to read sensitive information.
Examples in web applications:
- Passwords stored as hashes, never plaintext
- User A cannot read User B's private messages
- API keys are not exposed in client-side JavaScript
- Database credentials are not committed to source code
- HTTPS encrypts data in transit
When confidentiality is broken:
- SQL injection leaks the entire user table
- Insecure Direct Object Reference (IDOR) exposes another user's data
- Leaked
.envfile exposes database credentials - Logging sensitive fields (passwords, credit cards) to log files
Implementation techniques:
- Encryption at rest (database-level, field-level)
- Encryption in transit (TLS/HTTPS)
- Access control (who can read what)
- Data masking (showing
****1234instead of full card number)
Integrity
Integrity means ensuring that data is accurate and hasn't been modified by unauthorized parties — intentionally or accidentally.
Examples in web applications:
- Digitally signed JWT tokens (tampered payload is rejected)
- Checksums on file downloads
- Database transactions with ACID guarantees
- Input validation that rejects malformed data
- Audit logs that record who changed what
When integrity is broken:
- An attacker modifies a JWT payload (changing
"role": "user"to"role": "admin") - A price in a shopping cart is manipulated client-side
- An attacker performs a man-in-the-middle attack and modifies an API response
- SQL injection inserts or modifies records
Implementation techniques:
- Cryptographic signatures (JWT, HMAC, digital signatures)
- Input validation on the server (never trust the client)
- Parameterized queries (prevent data modification via injection)
- Checksums and hash verification
- Immutable audit logs
Availability
Availability means that systems and data are accessible to authorized users when they need them.
Examples in web applications:
- Rate limiting prevents a single user from overwhelming the API
- Auto-scaling handles traffic spikes
- Health checks restart failed services automatically
- CDN caching reduces origin server load
- Database replication allows failover
When availability is broken:
- Denial of Service (DoS) attack floods the server with requests
- A missing index causes a slow query that brings down the database
- A memory leak gradually exhausts server resources
- A malicious file upload fills up disk space
- A dependency outage cascades to your service
Implementation techniques:
- Rate limiting and throttling
- Circuit breakers for external dependencies
- Caching (Redis, CDN)
- Horizontal scaling and load balancing
- Health monitoring and auto-restart
CIA in Practice: Making Trade-offs
Real security decisions involve balancing all three:
| Scenario | C | I | A | Trade-off |
|---|---|---|---|---|
| End-to-end encrypted messages | ✅ High | ✅ High | ⚠️ Medium | Messages can't be recovered if key is lost |
| Public read-only API | ⚠️ Low | ✅ High | ✅ High | Data is public but must be accurate |
| Emergency admin override | ⚠️ Low | ⚠️ Low | ✅ High | Break-glass access sacrifices C and I for A |
| Offline-first app | ✅ High | ⚠️ Medium | ✅ High | Local changes may conflict with server |
Key insight: Maximizing all three simultaneously is impossible. Security is about making conscious trade-offs based on your threat model — not about achieving perfection.
Threat Modeling: Think Like an Attacker
Threat modeling is the process of systematically identifying what could go wrong with your application. Do it before writing code, not after a breach.
The goal: know your risks so you can prioritize your defenses.
The 4-Question Framework
Every threat model answers four questions:
- What are we building? — Understand the system
- What can go wrong? — Identify threats
- What are we going to do about it? — Plan mitigations
- Did we do a good enough job? — Validate the model
Step 1: Understand What You're Building
Start with a Data Flow Diagram (DFD). Map out:
- External entities — Users, third-party services, other systems
- Processes — Your application components
- Data stores — Databases, file systems, caches
- Data flows — How data moves between them
- Trust boundaries — Lines that separate different trust levels
Example for a simple blog with authentication:
Trust boundaries to mark:
- Internet → Load Balancer (untrusted input enters here)
- API → Database (trusted internal boundary)
- API → Email Service (external, partially trusted)
Step 2: Identify Threats with STRIDE
STRIDE is a mnemonic for six threat categories, developed by Microsoft. For each component in your DFD, ask: "Could this be STRIDEd?"
| Letter | Threat | Violates | Example |
|---|---|---|---|
| S | Spoofing | Authentication | Attacker pretends to be another user |
| T | Tampering | Integrity | Attacker modifies data in transit |
| R | Repudiation | Non-repudiation | User denies placing an order |
| I | Information Disclosure | Confidentiality | Error message leaks stack trace |
| D | Denial of Service | Availability | Flood of requests crashes the server |
| E | Elevation of Privilege | Authorization | Regular user accesses admin endpoint |
Applying STRIDE to the Blog Example
Login endpoint (POST /login):
| STRIDE | Threat | Mitigation |
|---|---|---|
| S | Attacker brute-forces credentials | Rate limiting, account lockout, CAPTCHA |
| T | Request is tampered mid-transit | HTTPS/TLS |
| R | User denies logging in | Audit log with IP, timestamp, user agent |
| I | Error message reveals "user not found" vs "wrong password" | Generic error: "Invalid credentials" |
| D | Flood of login requests | Rate limit per IP and per account |
| E | Bypass login entirely | Server-side session validation on every request |
Database (PostgreSQL):
| STRIDE | Threat | Mitigation |
|---|---|---|
| S | Attacker connects directly to DB | Firewall rules, no public exposure |
| T | SQL injection modifies records | Parameterized queries, ORM |
| R | DBA denies making a change | Database audit logging |
| I | SQL injection leaks sensitive tables | Parameterized queries, least privilege DB user |
| D | Query causes full table scan, brings down DB | Query timeouts, indexes, connection pooling |
| E | App DB user has SUPERUSER privileges | Use least-privilege DB accounts |
Step 3: Rate and Prioritize Threats
Not all threats are equal. Use DREAD scoring or a simpler Risk = Likelihood × Impact matrix:
Example prioritization:
| Threat | Likelihood | Impact | Risk | Priority |
|---|---|---|---|---|
| SQL injection on login | High | High | Critical | Fix first |
| Brute force login | High | Medium | High | Fix soon |
| Admin DB user | Low | High | High | Fix soon |
| Missing audit logs | Medium | Low | Medium | Plan |
| CAPTCHA bypass | Low | Low | Low | Later |
Step 4: Define Mitigations
For each threat, define a concrete mitigation:
Threat: SQL injection on login endpoint
Risk: Critical
Mitigation: Use parameterized queries via ORM (TypeORM/SQLAlchemy)
Validation: Code review + automated SQLi scanner in CI/CD
Owner: Backend team
Timeline: Before next releaseAttack Surface Analysis
Your attack surface is everything that can be reached by an attacker:
Rules for attack surface reduction:
- Disable features you don't use (remove unused routes, turn off debug endpoints)
- Minimize exposed ports (only 443 publicly, everything else internal)
- Remove unused dependencies
- Revoke unnecessary permissions
- Disable default accounts and change default credentials
Defense in Depth: Never Rely on One Layer
Defense in depth is the principle that security should come from multiple independent layers — so that if one layer fails, others still protect the system.
The classic analogy: a medieval castle doesn't rely just on its outer wall. It has a moat, an outer wall, a gatehouse, an inner wall, guards, and a keep. An attacker must defeat all of these.
The Security Layer Model
Each layer is independent. If an attacker bypasses the WAF (Layer 1), they still face TLS at Layer 2, auth at Layer 3, input validation at Layer 4, and parameterized queries at Layer 5.
Practical Defense in Depth for a Web API
Let's trace how defense in depth protects against a SQL injection attempt:
Attack: POST /api/login with body {"email": "admin'--", "password": "x"}
| Layer | Defense | What Happens |
|---|---|---|
| Network (WAF) | WAF rule detects SQLi pattern | Request blocked ✅ |
| Rate Limiting | 5 failed logins → block IP | — |
| Input Validation | Email format validation rejects admin'-- | Request blocked ✅ |
| Application Logic | Auth service uses ORM with parameterized queries | Query safe ✅ |
| Database | DB user only has SELECT on users table | No modification possible ✅ |
| Monitoring | Anomalous login pattern triggers alert | Security team notified ✅ |
Even if the WAF misses the attack, four other layers catch it. No single point of failure.
Defense in Depth Anti-Patterns
❌ Security through obscurity — Hiding API endpoints or using non-standard ports is not a security layer. Attackers can discover them.
❌ Trust but don't verify — Trusting that the load balancer validates auth, so the API doesn't check. If the LB is misconfigured, you have zero protection.
❌ Single authentication check — Only validating auth at the gateway, not in individual microservices. If one service is accessed directly (internal network breach), no auth applies.
❌ All-or-nothing access — Either full admin or no access. Missing fine-grained permissions means one compromised account gives full access.
Principle of Least Privilege
The principle of least privilege (PoLP) states that every system, user, and process should have only the minimum permissions needed to perform its function — nothing more.
Why It Matters
If an attacker compromises a component with excessive privileges, they inherit those privileges. Limiting permissions limits the blast radius.
Least Privilege in Practice
Database accounts:
-- ❌ BAD: App uses a superuser account
-- Attacker can DROP TABLE, CREATE USER, read pg_shadow
-- ✅ GOOD: App uses a restricted account
CREATE USER blog_app WITH PASSWORD 'secure_password';
GRANT SELECT, INSERT, UPDATE ON posts TO blog_app;
GRANT SELECT, INSERT ON comments TO blog_app;
GRANT SELECT ON users TO blog_app;
-- Can NOT: DELETE posts, access other schemas, create tablesFile system permissions:
# ❌ BAD: App runs as root, reads/writes everywhere
# ✅ GOOD: App runs as dedicated user with minimal access
useradd -r -s /bin/false blog_app
chown -R blog_app:blog_app /var/www/blog
chmod 750 /var/www/blog
chmod 640 /var/www/blog/config/database.ymlAPI keys and service accounts:
// ❌ BAD: One admin API key used everywhere
{
"stripe_key": "sk_live_admin_full_access_key"
}
// ✅ GOOD: Scoped keys for each purpose
{
"stripe_read_key": "rk_live_readonly_key", // for reporting
"stripe_charge_key": "sk_live_charges_only" // for payments
}AWS IAM (Infrastructure):
// ❌ BAD: Lambda function has AdministratorAccess
// ✅ GOOD: Lambda function has exactly what it needs
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-blog-uploads/*"
}]
}Least Privilege Checklist
✅ Database users have only the tables and operations they need
✅ Application process runs as a non-root user
✅ API keys and tokens have the minimum required scopes
✅ Cloud IAM roles follow least privilege
✅ Admin access requires separate credentials (not just a role flag)
✅ Service-to-service calls use separate credentials per service
✅ Credentials are rotated regularly
Fail-Safe Defaults
Fail-safe defaults means that when something goes wrong or a decision hasn't been made, the system defaults to the most secure state.
Examples in Code
❌ Fail-open (dangerous):
function canAccessResource(user: User, resourceId: string): boolean {
try {
return checkPermission(user, resourceId);
} catch (error) {
// Error in permission check — assume allowed!
return true; // ← WRONG: defaults to access granted
}
}✅ Fail-secure (correct):
function canAccessResource(user: User, resourceId: string): boolean {
try {
return checkPermission(user, resourceId);
} catch (error) {
logger.error('Permission check failed', { userId: user.id, resourceId, error });
return false; // ← CORRECT: defaults to access denied
}
}❌ Default to permissive CORS:
// Allows any origin — fails open
app.use(cors());✅ Default to restrictive CORS:
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [],
credentials: true,
}));❌ Missing authorization check:
// If middleware fails, request goes through
app.get('/admin/users', optionalAuth, listUsers);✅ Explicit authorization required:
// Must pass auth, no fallback
app.get('/admin/users', requireAuth, requireRole('admin'), listUsers);Zero Trust Architecture
Zero trust is an approach that assumes no implicit trust — not even for requests from inside your own network. Every request must be authenticated and authorized, every time.
The classic network security model was "castle and moat": trust everything inside the network perimeter, trust nothing outside. Zero trust replaces this with "never trust, always verify."
Zero Trust Principles
Zero Trust vs. Perimeter Security
| Aspect | Perimeter Security | Zero Trust |
|---|---|---|
| Trust model | Trust inside network | Trust nothing by default |
| Access control | Network location-based | Identity and context-based |
| Lateral movement | Easy (inside is trusted) | Hard (each hop requires auth) |
| Remote work | Risky (VPN complexity) | Native (identity is the perimeter) |
| Cloud | Hard to implement | Natural fit |
| Breach impact | Large (inside is trusted) | Limited (no implicit trust) |
Zero Trust in a Web Application
Even without a full zero trust infrastructure, you can apply the principles in your application:
Verify every request explicitly:
// ❌ Perimeter thinking: trust the internal network
app.get('/internal/admin', (req, res) => {
// "This endpoint is only accessible from internal network, so it's safe"
return res.json(await getAllUsers());
});
// ✅ Zero trust: authenticate every request regardless of source
app.get('/internal/admin', requireServiceToken, requireScope('admin:read'), async (req, res) => {
return res.json(await getAllUsers());
});Assume breach in your design:
// Design as if the attacker is already inside:
// - Encrypt sensitive data even in your own database
// - Use separate credentials for each service
// - Log all access to sensitive resources
// - Segment data so one compromised service can't read everything
const sensitiveField = encrypt(data.ssn, process.env.FIELD_ENCRYPTION_KEY);
await db.query(
'INSERT INTO users (email, ssn_encrypted) VALUES ($1, $2)',
[data.email, sensitiveField]
);Secure Development Lifecycle (SDLC)
Security should be part of every phase of development — not an afterthought tacked on before release.
Security in Each Phase
Security Requirements
When writing user stories, include security requirements:
Feature: User login
Functional requirement:
As a user, I want to log in with my email and password.
Security requirements:
- Passwords must be hashed with bcrypt (cost factor ≥ 12)
- Rate limit: max 5 failed attempts per 15 minutes per IP
- Lock account after 10 failed attempts; require email verification to unlock
- Log all login attempts (success and failure) with IP and user agent
- Return "Invalid credentials" for both wrong email and wrong password
- Session tokens must be 128-bit random, stored server-side
- Session expires after 24 hours of inactivityInput Validation Philosophy
Never trust input. Every piece of data that comes from outside your application boundary must be validated before use.
Sources of untrusted input:
- HTTP request body
- Query parameters and URL parameters
- HTTP headers (including
Cookie,Authorization,User-Agent) - File uploads
- WebSocket messages
- Data from external APIs (even "trusted" partners)
- Data from your own database (if it was created from user input)
// ❌ BAD: Trust the client
app.post('/posts', async (req, res) => {
const { title, content, authorId } = req.body;
await db.query('INSERT INTO posts ...', [title, content, authorId]);
});
// ✅ GOOD: Validate everything, override what the client shouldn't control
app.post('/posts', requireAuth, async (req, res) => {
const schema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
// authorId comes from the authenticated session, NOT from the request body
});
const { title, content } = schema.parse(req.body);
const authorId = req.user.id; // From auth middleware, not user input
await db.query('INSERT INTO posts ...', [title, content, authorId]);
});Output Encoding
Encode output for the context in which it appears. The same data needs different encoding in HTML, JavaScript, URLs, and SQL.
// HTML context: encode < > & " '
const safeHtml = escapeHtml(userInput);
// e.g., <script> becomes <script>
// URL context: encode special characters
const safeUrl = encodeURIComponent(userInput);
// e.g., "hello world" becomes "hello%20world"
// JavaScript context: encode for JS strings
const safeJs = JSON.stringify(userInput);
// Properly escapes quotes and special chars
// SQL context: use parameterized queries (never string concatenation)
await db.query('SELECT * FROM users WHERE email = $1', [userInput]);Putting It All Together: A Practical Example
Let's apply all these concepts to a real feature: a user comment system on a blog.
Threat Modeling the Comment System
What are we building?
- Users submit comments on blog posts
- Comments are stored in a database
- Comments are displayed to all visitors
What can go wrong? (STRIDE)
| Threat | Category | Risk |
|---|---|---|
| Attacker posts XSS payload in comment | Tampering + Info Disclosure | Critical |
| Spam bot floods comment section | DoS | High |
| User posts comment as another user | Spoofing | High |
| Admin deletes comment, user denies it happened | Repudiation | Medium |
| IDOR: edit/delete another user's comment | Elevation of Privilege | High |
Defense in depth for comments:
Implementation checklist:
- ✅ Rate limit comment submissions per user
- ✅ Require authentication to post
- ✅ Validate comment length (min 1, max 2000 chars)
- ✅ Sanitize HTML to allow safe tags only (e.g.,
<b>,<i>) or strip all HTML - ✅ Store
author_idfrom the session, never from the request body - ✅ Check ownership before allowing edit/delete
- ✅ Parameterize all database queries
- ✅ HTML-encode comment content on render
- ✅ Log all comment actions with timestamp, user, IP
Common Beginner Mistakes
Mistake 1: Validating Only on the Client
// ❌ Frontend validation only
<input type="email" required maxlength="100" />
// ✅ Always validate on the server too
app.post('/subscribe', (req, res) => {
const { email } = req.body;
if (!isValidEmail(email) || email.length > 100) {
return res.status(400).json({ error: 'Invalid email' });
}
// ...
});Client-side validation is UX. Server-side validation is security. An attacker will use curl or Burp Suite to bypass your frontend entirely.
Mistake 2: Trusting Internal Services
// ❌ "This is an internal microservice, no auth needed"
app.get('/internal/user/:id', (req, res) => {
return User.findById(req.params.id);
});
// ✅ Zero trust: authenticate every service-to-service call
app.get('/internal/user/:id', verifyServiceToken, (req, res) => {
return User.findById(req.params.id);
});If your internal network is ever compromised (lateral movement, cloud misconfiguration, malicious insider), unprotected internal services become easy targets.
Mistake 3: Verbose Error Messages
// ❌ Leaks information about the system
try {
await loginUser(email, password);
} catch (error) {
if (error.code === 'USER_NOT_FOUND') {
res.status(401).json({ error: 'User not found' }); // Confirms user doesn't exist
} else if (error.code === 'WRONG_PASSWORD') {
res.status(401).json({ error: 'Incorrect password' }); // Confirms user exists
}
}
// ✅ Generic message that reveals nothing
try {
await loginUser(email, password);
} catch (error) {
logger.warn('Login failed', { email, reason: error.code }); // Log details internally
res.status(401).json({ error: 'Invalid credentials' }); // Generic to the client
}Mistake 4: Security as an Afterthought
Week 1-3: Build features
Week 4: "Let's add security"
Week 5: Launch (security partially done)Security bolted on at the end is always incomplete. Build it in from the start — each user story should have security requirements, each PR should include a security review checklist.
Security Checklist for Every Feature
Before marking any feature as "done," verify:
Authentication & Authorization:
✅ Every endpoint has appropriate auth check
✅ Authorization is checked at the data layer, not just routing
✅ Users can only access their own data (or explicitly shared data)
Input & Output:
✅ All user input is validated on the server
✅ Output is encoded for its context (HTML, JSON, URL)
✅ Error messages don't reveal internal details
Data Security:
✅ Sensitive data is not logged
✅ Database queries use parameterized statements
✅ Sensitive fields are encrypted at rest
Availability:
✅ Rate limiting is applied to state-changing endpoints
✅ File upload size limits are enforced
✅ Query timeouts are configured
Observability:
✅ Security-relevant actions are logged (login, logout, permission denied, data access)
✅ Logs don't contain sensitive data (passwords, tokens, PII)
Summary and Key Takeaways
✅ The CIA Triad (Confidentiality, Integrity, Availability) is the foundation of every security decision
✅ Threat modeling with STRIDE helps you systematically find what can go wrong before you build
✅ Attack surface is everything an attacker can reach — minimize it ruthlessly
✅ Defense in depth means multiple independent layers — no single point of failure
✅ Principle of least privilege limits blast radius when something is compromised
✅ Fail-safe defaults mean your system defaults to secure when uncertain
✅ Zero trust means never implicit trust — authenticate and authorize every request
✅ Validate all input server-side — client-side validation is UX, not security
✅ Security belongs in every phase of development — not as a final step
What's Next
You've built your security mindset. Now let's look at the specific vulnerabilities that affect real applications:
Next: Phase 2: OWASP Top 10 Explained — All 10 vulnerability categories, how they work, and how to prevent them.
Related Posts
- Web Security Learning Roadmap — Full series overview
- HTTP Protocol Complete Guide — HTTPS, headers, and cookies
- Spring Boot JWT Authentication — Auth implementation in Java
- FastAPI Authentication — JWT & OAuth2 in Python
This is Part 2 of the Web Security Learning Roadmap. Start from the beginning or jump to any topic that interests you.
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.