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
- TLS termination: all external connections must be HTTPS
- Rate limiting: limit the number of requests per IP or per user
- Token validation: verify the JWT is valid before routing
- Header filtering: strip internal headers that shouldn’t reach the outside
- 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
- Safe data transformation: never expose internal data to the frontend
- Input validation: a second layer of validation after the frontend
- Secure orchestration: when combining data from multiple services, respect the user’s permissions
- 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.