Security by Layer

How to apply security at every layer of the architecture: frontend, BFF, microservices, and database, with specific strategies for each level.

The defense-in-depth principle

Security in a distributed architecture cannot depend on a single point of control. If an attacker gets past one barrier, they should hit another. This principle is called defense in depth: each layer implements its own security measures, regardless of what the other layers do.

[Frontend] → [API Gateway] → [BFF] → [Microservices] → [Database]
  Layer 1        Layer 2      Layer 3     Layer 4          Layer 5

Each layer faces specific threats and therefore needs specific protections.

Frontend Security

The frontend is the most exposed attack surface: any user can inspect the code, tamper with requests, and modify the DOM.

What to protect

  • User inputs: any data entered by the user is potentially malicious
  • Session tokens: if an attacker obtains the token, they can impersonate the user
  • Sensitive data: never store secrets, API keys, or confidential data in the frontend

Key strategies

Input sanitization

Never trust user data. Validate and sanitize on the frontend as a first line of defense, but always validate on the backend too.

// ❌ Dangerous: inserting user HTML directly
element.innerHTML = userInput;

// ✅ Safe: use textContent or sanitize
element.textContent = userInput;
// Or use a sanitization library like DOMPurify
element.innerHTML = DOMPurify.sanitize(userInput);

Secure token storage

// ❌ Vulnerable to XSS: localStorage is accessible from any script
localStorage.setItem('token', jwt);

// ✅ Better: httpOnly cookies (configured from the server)
// The frontend can't access the cookie, but it's sent automatically
Set-Cookie: token=jwt; HttpOnly; Secure; SameSite=Strict

Content Security Policy (CSP)

Configure CSP headers to limit what resources the page can load:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.ejemplo.com;

API Gateway Security

The API Gateway is the system’s single entry point. It’s the ideal place to implement cross-cutting security controls.

Security responsibilities

  1. TLS termination: all external connections must be HTTPS
  2. Rate limiting: limit the number of requests per IP or per user
  3. Token validation: verify the JWT is valid before routing
  4. Header filtering: strip internal headers that shouldn’t reach the outside
  5. Access logging: log every request for auditing

Rate limiting

# Example rate limiting configuration
rate_limit:
  global:
    requests_per_second: 100
  per_ip:
    requests_per_minute: 60
  per_user:
    requests_per_minute: 120
  endpoints:
    /auth/login:
      requests_per_minute: 5  # More restrictive for login

Token validation at the Gateway

Incoming request
  → Does it have an Authorization header?
    → No → Is it a public route? → Yes → Allow
                                  → No → 401 Unauthorized
    → Yes → Is the token valid (signature, expiration)?
      → No → 401 Unauthorized
      → Yes → Extract claims, attach to request, route

BFF Security

The BFF (Backend for Frontend) acts as an intermediary between the frontend and the microservices. It has specific security responsibilities.

Responsibilities

  1. Safe data transformation: never expose internal data to the frontend
  2. Input validation: a second layer of validation after the frontend
  3. Secure orchestration: when combining data from multiple services, respect the user’s permissions
  4. Error handling: don’t leak internal information in error messages

Example: filtering sensitive data

// Response from the microservice (internal data)
const userFromService = {
  id: "user-123",
  email: "maria@ejemplo.com",
  passwordHash: "$2b$10$...",  // Never expose this!
  internalId: "db-row-456",    // Internal data!
  role: "admin",
  lastLogin: "2024-01-15T10:30:00Z"
};

// BFF response to the frontend (filtered data)
const userForFrontend = {
  id: userFromService.id,
  email: userFromService.email,
  role: userFromService.role,
  lastLogin: userFromService.lastLogin
};

Secure error handling

// ❌ Exposes internal information
res.status(500).json({
  error: "Connection refused to postgres://db-host:5432/users"
});

// ✅ Generic error for the frontend
res.status(500).json({
  error: "Internal server error. Please try again."
});
// The details are logged internally

Microservices Security

Every microservice must assume that the requests it receives could be malicious, even if they come from other internal services.

Zero Trust between services

The Zero Trust model states that no service automatically trusts another, even within the same network:

  • Every service verifies the caller’s identity
  • Every service validates permissions for the requested operation
  • Communication between services is encrypted (mTLS)

Input validation in each service

// Each microservice validates its own inputs
function createOrder(data) {
  // Validate structure
  if (!data.userId || !data.items || data.items.length === 0) {
    throw new ValidationError("Invalid order data");
  }
  
  // Validate types and ranges
  if (typeof data.userId !== 'string' || data.userId.length > 50) {
    throw new ValidationError("Invalid userId");
  }
  
  for (const item of data.items) {
    if (item.quantity < 1 || item.quantity > 1000) {
      throw new ValidationError("Quantity out of range");
    }
  }
  
  // Process only after validating
  return orderRepository.create(data);
}

Principle of least privilege

Each microservice should only have access to the resources it needs:

  • The orders service doesn’t need access to the users table
  • The notifications service doesn’t need to be able to modify orders
  • Database credentials are unique per service

Database Security

The database is the last line of defense. If an attacker reaches this point, the data must still be protected.

Key strategies

Encryption at rest

Sensitive data must be encrypted on disk:

-- Example: encrypted columns
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,  -- Hash, not reversible encryption
  ssn_encrypted BYTEA,                  -- Sensitive data encrypted with AES-256
  created_at TIMESTAMP DEFAULT NOW()
);

Least-privilege access

-- Each service has its own DB user with limited permissions
-- Orders service: only access to order-related tables
GRANT SELECT, INSERT, UPDATE ON orders TO orders_service;
GRANT SELECT ON products TO orders_service;
-- No access to the users table

-- Users service: only access to user-related tables
GRANT SELECT, INSERT, UPDATE ON users TO users_service;
-- No access to the orders table

SQL injection prevention

// ❌ Vulnerable to SQL injection
const query = `SELECT * FROM users WHERE email = '${email}'`;

// ✅ Parameterized query
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [email]);

Access auditing

Log who accesses what data and when:

CREATE TABLE audit_log (
  id SERIAL PRIMARY KEY,
  table_name VARCHAR(100),
  operation VARCHAR(10),  -- SELECT, INSERT, UPDATE, DELETE
  user_id VARCHAR(100),
  service_name VARCHAR(100),
  timestamp TIMESTAMP DEFAULT NOW(),
  details JSONB
);

Summary

Layered security guarantees there’s no single point of failure. Each layer — frontend, API Gateway, BFF, microservices, and database — implements its own defenses. The frontend sanitizes inputs and protects tokens, the Gateway controls access and throttles traffic, the BFF filters sensitive data, the microservices validate everything and enforce least privilege, and the database encrypts and audits. Together, these layers create a system where compromising one doesn’t mean compromising all.