Jhune Carlo Trogelio3 min read

4 Principles of Secure Software Design Every Developer Should Know

Security is not a feature you bolt on after launch. These four foundational principles — least privilege, defense in depth, fail-safe defaults, and zero trust — should shape every architectural decision you make.

Most security breaches do not happen because of sophisticated zero-day exploits. They happen because a developer hardcoded an API key, forgot to validate user input, or gave a service account more permissions than it needed.

After years of building production systems — from fintech platforms handling financial data to blockchain applications managing wallet transactions — I have learned that security is not a checklist. It is a design philosophy. You either build it into your architecture from day one, or you spend the rest of the project's life patching holes.

Here are the four principles that guide every system I design.

1. Principle of Least Privilege

Every user, service, and process should have only the minimum permissions required to do its job — nothing more.

This sounds obvious. In practice, it is the most commonly violated principle in software engineering.

Where Developers Get This Wrong

  • Database users with root access: Your application's database connection string should not have DROP TABLE permissions. Create a dedicated user with only SELECT, INSERT, UPDATE, and DELETE on the specific tables it needs.
  • Overly permissive IAM roles: An AWS Lambda that reads from S3 does not need s3:*. It needs s3:GetObject on a specific bucket.
  • Admin-by-default user roles: New users should start with the least possible access. Elevate permissions explicitly, not implicitly.

How I Apply This

When I built role-based access control for Pulsar, the analytics platform, every permission was opt-in, not opt-out. New team members could view dashboards but could not modify data sources, invite users, or change billing. Each elevation required explicit assignment by an admin — with an audit trail.

// Bad: implicit broad access
const canAccess = user.role !== 'guest'

// Good: explicit permission checks
const canModifyDataSource = user.permissions.includes('datasource:write')
const canInviteUsers = user.permissions.includes('team:invite')

The rule is simple: if a permission is not explicitly granted, it does not exist.

2. Defense in Depth

Never rely on a single layer of security. Stack multiple independent defenses so that if one fails, the others still protect you.

Think of it like a medieval castle: you do not just build a wall. You build a moat, a wall, an inner wall, guard towers, and a keep. If the enemy breaches one layer, they face another.

Layers Every Web Application Should Have

  1. Network layer: Firewalls, VPCs, security groups. Your database should never be publicly accessible.
  2. Transport layer: TLS everywhere. No exceptions. Not just for login pages — for every request.
  3. Application layer: Input validation, output encoding, parameterized queries. Treat all user input as hostile.
  4. Authentication layer: Multi-factor authentication, session management, token rotation.
  5. Authorization layer: Role-based or attribute-based access control on every endpoint.
  6. Data layer: Encryption at rest, encryption in transit, field-level encryption for sensitive data.
  7. Monitoring layer: Audit logs, anomaly detection, alerting on suspicious patterns.

A Real Example

When I worked on Dataloft — a client-side encrypted storage platform built at ETHGlobal — we stacked defenses at every level:

  • Client-side encryption: Files were encrypted in the browser before upload. The server never saw plaintext data.
  • Web3 authentication: Metamask wallet signatures replaced passwords entirely. No credentials stored on our servers.
  • Decentralized storage: Files were distributed across IPFS and Filecoin. No single point of failure.
  • Zero server-side keys: We never had access to user data. Even if our servers were compromised, the attacker would get nothing useful.

Any single layer could theoretically be bypassed. Together, they made the system practically impenetrable for its threat model.

3. Fail-Safe Defaults

When something goes wrong — and it will — the system should fail into a secure state, not an open one.

This principle defines how your application behaves when it encounters the unexpected: a timeout, a malformed token, a missing configuration value, or an unhandled exception.

The Wrong Way

// Dangerous: fails open
function checkAccess(token) {
  try {
    const user = verifyToken(token)
    return user.hasPermission('admin')
  } catch (error) {
    // Token verification failed, but let them through anyway
    return true
  }
}

The Right Way

// Safe: fails closed
function checkAccess(token) {
  try {
    const user = verifyToken(token)
    return user.hasPermission('admin')
  } catch (error) {
    logger.warn('Token verification failed', { error })
    return false
  }
}

Where This Matters Most

  • Environment variables: If a required secret is missing, the application should refuse to start, not fall back to a default value.
  • API rate limiting: If the rate limiter service is down, deny requests rather than allowing unlimited access.
  • Feature flags: If the flag service is unreachable, disable the feature, do not enable it.
  • CORS configuration: If the allowed origins list is empty or misconfigured, block all cross-origin requests.
// Fail-safe: refuse to start without critical config
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', 'ENCRYPTION_KEY']

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(
      `Missing required environment variable: ${envVar}. Refusing to start.`
    )
  }
}

The principle is straightforward: when in doubt, deny.

4. Zero Trust Architecture

Never assume that any request, user, or service is trustworthy — regardless of where it originates.

The traditional security model was "trust everything inside the network perimeter." Zero trust flips this: trust nothing, verify everything.

What Zero Trust Means in Practice

  • Authenticate every request: Even internal service-to-service calls should carry authentication tokens. A compromised internal service should not be able to freely access other services.
  • Validate at every boundary: Do not assume that because the frontend validated input, the backend can skip validation. Validate again.
  • Short-lived credentials: Use tokens with short expiration times. Rotate API keys regularly. Never use long-lived credentials for automated systems.
  • Microsegmentation: Services should only be able to communicate with the specific services they need. A payment service should not be able to query the user profile service directly.

How I Implement This

In every NestJS backend I build, I follow a pattern:

// Every endpoint validates its own authentication
@UseGuards(JwtAuthGuard)
@UseGuards(RolesGuard)
@Roles('admin')
@Post('users/:id/elevate')
async elevateUser(@Param('id') id: string) {
  // Even though the request passed through API gateway auth,
  // we verify again at the service level
}
// Service-to-service calls carry their own auth
async function callPaymentService(data) {
  const serviceToken = await getServiceToken('payment-service')
  return fetch(PAYMENT_URL, {
    headers: {
      Authorization: `Bearer ${serviceToken}`,
      'X-Request-ID': generateRequestId(),
    },
    body: JSON.stringify(data),
  })
}

The mental model: treat every boundary as if it faces the public internet, because in a breach scenario, it might.

Putting It All Together

These four principles are not independent — they reinforce each other:

  • Least privilege limits the blast radius when a breach occurs
  • Defense in depth ensures a single failure does not compromise the system
  • Fail-safe defaults guarantee that failures degrade toward security, not away from it
  • Zero trust eliminates the assumption that any layer is inherently safe

When I set up CI/CD pipelines, I apply the same thinking:

  • Pipeline service accounts have least privilege — only the permissions needed to deploy
  • Secrets are stored in vault services with defense in depth — encrypted at rest, accessed via short-lived tokens
  • If secret injection fails, the pipeline fails safe — it stops, it does not deploy with empty credentials
  • Every stage authenticates independently — zero trust between build, test, and deploy phases

Security Is a Culture, Not a Feature

You cannot add security to a codebase the way you add a feature. It is not a ticket you close. It is a design philosophy that influences every decision — from how you structure your database queries to how you configure your deployment pipeline.

The four principles in this article are not new. They have been documented in security literature for decades. But the gap between knowing them and consistently applying them is where most vulnerabilities live.

Build security into the architecture. Make it the default. Make it invisible to the user but impossible to bypass.

That is how you ship software you can trust.