Security Is an Architecture Concern

Security gets treated as a feature set: authentication, authorization, input validation, encryption. Implement those features and the system is secure. This framing is wrong and produces systems that are secure in obvious ways while vulnerable in non-obvious ones.

Security is an emergent property of architectural decisions: where trust boundaries are placed, how data flows between components, what permissions components have, how failures are handled. These decisions are made during design, not during a security review at the end.

Threat Modeling: Starting With Adversarial Thinking

Security work should start with threat modeling — systematically thinking about what an adversary could do and what the consequences would be.

The STRIDE framework provides a useful taxonomy:

  • Spoofing: impersonating a user or system
  • Tampering: modifying data or code
  • Repudiation: denying an action occurred
  • Information Disclosure: exposing data to unauthorized parties
  • Denial of Service: preventing legitimate use
  • Elevation of Privilege: gaining permissions beyond what’s authorized

For each significant component and data flow, ask: what could an adversary do? What’s the impact? What’s the likelihood?

Simple threat modeling exercise for an API:

Component: Order API endpoint (POST /orders)
Data flows: 
  - User credentials → Auth service
  - Order data → Orders service  
  - Payment details → Payment service

Threats:
T1: SPOOFING — attacker impersonates authenticated user
    Impact: orders placed under victim's account
    Mitigation: short-lived JWT tokens, MFA for sensitive operations

T2: TAMPERING — attacker modifies order data in transit
    Impact: wrong items/quantities ordered
    Mitigation: HTTPS, request signing

T3: INFORMATION DISCLOSURE — order data leaks
    Impact: customer data exposure, GDPR violation  
    Mitigation: authorization checks on every read, minimal data in logs

T4: ELEVATION OF PRIVILEGE — user accesses other users' orders
    Impact: data exposure, potential for fraud
    Mitigation: resource-level authorization (verify order belongs to user)

This doesn’t need to be exhaustive. Even a 30-minute threat modeling exercise before architecture decisions surfaces the risks that deserve deliberate design.

Trust Boundaries

A trust boundary is a line where the security context changes. Every component and data flow crosses trust boundaries.

Explicitly designing trust boundaries reveals implicit trust relationships that shouldn’t exist:

External (no trust)
  ↓ [public internet]
Load Balancer / WAF (minimal trust — rate limiting, basic filtering)
  ↓ [internal network]
API Gateway (authenticated trust — JWT verification)
  ↓ [service mesh with mTLS]
Services (service identity trust — knows who's calling)
  ↓ [VPC internal]
Database (no external access — only from specific service IPs)
  ↓
Storage (encrypted at rest — no direct access from applications)

Implicit trust is dangerous. “Services in our VPC can trust each other” is an implicit trust model that means a compromise of any service gives an attacker access to all services. Service mesh mTLS, service-to-service JWT tokens, or policy-based authorization (OPA) at the service level reduces the blast radius of any single service compromise.

Least Privilege in Practice

The principle of least privilege: components should have the minimum permissions required to do their job.

This applies at every layer:

IAM roles (AWS example):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::orders-bucket/orders/*"
    }
  ]
}

Not s3:*. Not arn:aws:s3:::*. Specifically what this service needs.

Database permissions:

-- Create a role with only what the application needs
CREATE ROLE orders_app_role;
GRANT SELECT, INSERT, UPDATE ON orders.orders TO orders_app_role;
GRANT SELECT ON orders.products TO orders_app_role;
-- NOT: GRANT ALL PRIVILEGES

-- The application user gets this role
CREATE USER orders_app WITH PASSWORD '...';
GRANT orders_app_role TO orders_app;

An application user that can only SELECT, INSERT, UPDATE on specific tables cannot DROP tables, cannot access other schemas, and cannot be used for SQL injection attacks that go beyond reading/writing orders data.

Service-to-service permissions: each service’s token should only authorize it to call the specific endpoints it needs. An inventory service token shouldn’t authorize calls to the payment service.

Secrets Management

Hardcoded credentials and credentials in environment variables are common and dangerous. Environment variables are logged, visible in container orchestration UIs, and accessible to every process in the container.

Use a secrets manager:

  • AWS Secrets Manager / Parameter Store
  • HashiCorp Vault
  • Kubernetes Secrets (with encryption at rest enabled)
// Spring Boot with AWS Secrets Manager
@Configuration
public class DatabaseConfig {
    
    @Value("${spring.datasource.password}")  // Injected from Secrets Manager
    private String dbPassword;
}

// application.yml
spring:
  config:
    import: aws-secretsmanager:/production/postgres
  datasource:
    password: ${DB_PASSWORD}

With Spring Cloud AWS, secrets from AWS Secrets Manager are injected as Spring properties. The credentials are never on disk, never in environment variables, and rotated automatically.

Secrets rotation: credentials should rotate regularly. Systems that can’t survive credential rotation are fragile. Design for rotation from the start.

Dependency Security

The npm/Maven/Gradle dependency graph is an attack surface. Supply chain attacks (malicious packages) and known vulnerabilities in dependencies are real vectors.

Dependency scanning in CI/CD:

# GitLab CI
dependency-scan:
  image: owasp/dependency-check:latest
  script:
    - /usr/share/dependency-check/bin/dependency-check.sh 
        --project "myapp" 
        --scan "."
        --format "JSON"
        --failOnCVSS 7  # Fail on high severity
  artifacts:
    when: always
    paths: [dependency-check-report.json]

Tools: OWASP Dependency-Check, Snyk, GitHub Dependabot, Trivy.

Pin dependency versions in production. latest is convenient; it’s also “whatever the registry says today,” which may be compromised. Pin to specific versions and update deliberately.

Minimize the dependency surface. Every dependency is a potential vulnerability. Libraries that are small, maintained, and well-audited are preferable to large, complex ones.

Data Classification

Not all data has the same sensitivity. A security architecture that treats everything the same is either too restrictive (slows down legitimate work) or too permissive (exposes sensitive data).

Define data classification levels:

  • Public: can be shown to anyone
  • Internal: for authenticated users of the system
  • Confidential: specific role/permission required
  • Sensitive/PII: personal data, payment data, credentials — additional controls

Data flows should be designed around classification: sensitive data doesn’t flow through systems designed for public data, logs don’t capture sensitive values, caches don’t store credentials.

// Log sanitization — don't log sensitive fields
public record OrderRequest(
    String productId,
    int quantity,
    @Sensitive String cardNumber,  // Custom annotation
    @Sensitive String cvv
) {
    @Override
    public String toString() {
        // Custom toString that masks sensitive fields
        return "OrderRequest{productId=%s, quantity=%d, cardNumber=****}"
            .formatted(productId, quantity);
    }
}

Network Security

Default-deny network policies: by default, no service can talk to any other service. Explicitly allow only required communication.

# Kubernetes NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: orders-policy
spec:
  podSelector:
    matchLabels:
      app: orders-service
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: api-gateway  # Only allow from API gateway
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: postgres     # Only allow to postgres
    ports:
    - port: 5432

An attacker who compromises one service is contained to that service’s allowed network connections.

Security as Design, Not Review

Security reviews at the end of a project are useful for catching obvious mistakes. They can’t fix architectural decisions that have been committed to.

Security architecture decisions happen at the start:

  • Where are the trust boundaries?
  • What data is sensitive and how does it flow?
  • What permissions does each component need?
  • What happens when a component is compromised?

These questions are architectural questions. Answer them during design, not during a security review after the architecture is finalized.

securityarchitecturethreat-modelingzero-trustsecrets-management
← All articles