Back to blog

Web Security Fundamentals: CIA Triad & Threat Modeling

securityweb-securityowaspthreat-modelingtutorial
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 .env file 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 ****1234 instead 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:

ScenarioCIATrade-off
End-to-end encrypted messages✅ High✅ High⚠️ MediumMessages can't be recovered if key is lost
Public read-only API⚠️ Low✅ High✅ HighData is public but must be accurate
Emergency admin override⚠️ Low⚠️ Low✅ HighBreak-glass access sacrifices C and I for A
Offline-first app✅ High⚠️ Medium✅ HighLocal 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:

  1. What are we building? — Understand the system
  2. What can go wrong? — Identify threats
  3. What are we going to do about it? — Plan mitigations
  4. 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?"

LetterThreatViolatesExample
SSpoofingAuthenticationAttacker pretends to be another user
TTamperingIntegrityAttacker modifies data in transit
RRepudiationNon-repudiationUser denies placing an order
IInformation DisclosureConfidentialityError message leaks stack trace
DDenial of ServiceAvailabilityFlood of requests crashes the server
EElevation of PrivilegeAuthorizationRegular user accesses admin endpoint

Applying STRIDE to the Blog Example

Login endpoint (POST /login):

STRIDEThreatMitigation
SAttacker brute-forces credentialsRate limiting, account lockout, CAPTCHA
TRequest is tampered mid-transitHTTPS/TLS
RUser denies logging inAudit log with IP, timestamp, user agent
IError message reveals "user not found" vs "wrong password"Generic error: "Invalid credentials"
DFlood of login requestsRate limit per IP and per account
EBypass login entirelyServer-side session validation on every request

Database (PostgreSQL):

STRIDEThreatMitigation
SAttacker connects directly to DBFirewall rules, no public exposure
TSQL injection modifies recordsParameterized queries, ORM
RDBA denies making a changeDatabase audit logging
ISQL injection leaks sensitive tablesParameterized queries, least privilege DB user
DQuery causes full table scan, brings down DBQuery timeouts, indexes, connection pooling
EApp DB user has SUPERUSER privilegesUse 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:

ThreatLikelihoodImpactRiskPriority
SQL injection on loginHighHighCriticalFix first
Brute force loginHighMediumHighFix soon
Admin DB userLowHighHighFix soon
Missing audit logsMediumLowMediumPlan
CAPTCHA bypassLowLowLowLater

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 release

Attack 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"}

LayerDefenseWhat Happens
Network (WAF)WAF rule detects SQLi patternRequest blocked ✅
Rate Limiting5 failed logins → block IP
Input ValidationEmail format validation rejects admin'--Request blocked ✅
Application LogicAuth service uses ORM with parameterized queriesQuery safe ✅
DatabaseDB user only has SELECT on users tableNo modification possible ✅
MonitoringAnomalous login pattern triggers alertSecurity 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 tables

File 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.yml

API 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

AspectPerimeter SecurityZero Trust
Trust modelTrust inside networkTrust nothing by default
Access controlNetwork location-basedIdentity and context-based
Lateral movementEasy (inside is trusted)Hard (each hop requires auth)
Remote workRisky (VPN complexity)Native (identity is the perimeter)
CloudHard to implementNatural fit
Breach impactLarge (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 inactivity

Input 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 &lt;script&gt;
 
// 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)

ThreatCategoryRisk
Attacker posts XSS payload in commentTampering + Info DisclosureCritical
Spam bot floods comment sectionDoSHigh
User posts comment as another userSpoofingHigh
Admin deletes comment, user denies it happenedRepudiationMedium
IDOR: edit/delete another user's commentElevation of PrivilegeHigh

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_id from 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.



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.