Skip to content

Security Risks of Gateway-Only Authentication in Microservices

I deployed a microservices architecture with authentication handled entirely at the API gateway. Everything worked great until a misconfigured firewall rule exposed our internal services. An attacker gained access to a single compromised pod and then had unfettered access to every service in the cluster—including the payment service.

The outage lasted 4 hours. The post-mortem revealed a hard truth: we had bet everything on the internal network being impenetrable. It wasn’t.

The False Assumption

Gateway-only authentication rests on a single assumption: your internal network is a safe haven. The gateway validates every incoming request, strips user credentials, and forwards requests to internal services. Those services trust the gateway implicitly.

text ┌─────────────────────────────────────────────────────────────────┐ │ External Traffic │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────┐ │ API Gateway │ │ - Auth Validation │ │ - Rate Limiting │ │ - Request Routing │ └───────────────────────┘ │ ┌───────────┴───────────┐ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Service A │ │ Service B │ │ (No Auth) │ │ (No Auth) │ └─────────────┘ └─────────────┘ │ │ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Service C │ │ Payments │ │ (No Auth) │ │ (No Auth) │ └─────────────┘ └─────────────┘

Internal Network (Trusted?) text

The architecture looks clean. But clean doesn’t mean secure.

What Actually Goes Wrong

Security-minded developers on Reddit pointed out the flaw immediately. One comment with 8 upvotes cut to the core:

“If my database is in a private VPC does that mean I should just disable auth? Security works best in layers. Sure, you shouldn’t need auth inside the private VPC if everything is going right. But if someone becomes able to execute code inside your VPC, you’d probably be a lot better off if you also had auth on everything. One fat-fingered firewall rule breaks this whole setup.”

The scenarios that break gateway-only auth aren’t theoretical:

Misconfigured Firewall Rules: A single infrastructure-as-code error can expose internal ports. I’ve seen this happen during emergency patches at 2 AM.

Compromised Dependencies: A malicious package in your supply chain can execute code inside your cluster. Your gateway won’t help when the attack originates from within.

Insider Threats: Disgruntled employees or contractors with internal access bypass the gateway entirely.

Pod Escape: Container vulnerabilities can allow attackers to jump between pods, moving laterally through your services.

Risk Model Comparison

text ┌─────────────────────────────────────────────────────────────────────────────┐ │ Attack Vector Analysis │ ├──────────────────────┬──────────────────────────────────────────────────────┤ │ Attack Vector │ Gateway-Only Impact │ ├──────────────────────┼──────────────────────────────────────────────────────┤ │ External Attacker │ BLOCKED - Gateway validates credentials │ ├──────────────────────┼──────────────────────────────────────────────────────┤ │ Compromised Pod │ FULL ACCESS - All services accept any request │ ├──────────────────────┼──────────────────────────────────────────────────────┤ │ Misconfigured Firewall│ FULL ACCESS - Internal services exposed │ ├──────────────────────┼──────────────────────────────────────────────────────┤ │ Insider Threat │ FULL ACCESS - Already inside the network │ ├──────────────────────┼──────────────────────────────────────────────────────┤ │ Supply Chain Attack │ FULL ACCESS - Code runs inside trusted network │ └──────────────────────┴──────────────────────────────────────────────────────┘ text

The gateway stops external attackers. But 80% of breaches involve compromised credentials or internal access. Gateway-only auth has no answer for these.

Defense in Depth Levels

I learned to think about service authentication in three levels:

text Level 1: Network Isolation (Baseline) ├── Private VPC/VNET ├── Firewall rules └── Security groups

Level 2: Service Identity (Recommended) ├── Network Isolation ├── Service-to-service auth (mTLS or tokens) └── Audit logging

Level 3: Zero Trust (Highest) ├── Service Identity ├── Mutual TLS everywhere ├── Short-lived credentials ├── Continuous verification └── Least privilege per service text

Level 1: Network Isolation

Start with Kubernetes NetworkPolicies to restrict which pods can communicate:

network-policy-payment.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payment-service-policy
namespace: production
spec:
podSelector:
matchLabels:
app: payment-service
policyTypes:
- Ingress
- Egress
ingress:
# Only allow traffic from order service
- from:
- podSelector:
matchLabels:
app: order-service
ports:
- protocol: TCP
port: 443
# Allow health checks from prometheus
- from:
- namespaceSelector:
matchLabels:
name: monitoring
ports:
- protocol: TCP
port: 8080
egress:
# Allow database access
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow DNS resolution
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53

This limits blast radius. A compromised logging service can’t reach the payment service.

Level 2: Service Identity with mTLS

Istio automates service-to-service authentication:

istio-peer-authentication.yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: payment-mtls
namespace: production
spec:
selector:
matchLabels:
app: payment-service
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: payment-authz
namespace: production
spec:
selector:
matchLabels:
app: payment-service
rules:
- from:
- source:
principals:
- "cluster.local/ns/production/sa/order-service"
to:
- operation:
methods: ["POST", "GET"]
paths: ["/api/payments/*"]

Every service gets a cryptographic identity. Services verify each other before communicating.

Service Identity Tokens (Without Service Mesh)

If you’re not ready for Istio, implement service identity with tokens:

service-identity.ts
import crypto from 'crypto';
interface ServiceIdentity {
service: string;
instance: string;
issuedAt: number;
expiresAt: number;
}
export class ServiceIdentityToken {
private readonly secretKey: string;
private readonly ttlSeconds: number = 300; // 5 minutes
constructor(secretKey: string) {
this.secretKey = secretKey;
}
generate(serviceName: string, instanceId: string): string {
const identity: ServiceIdentity = {
service: serviceName,
instance: instanceId,
issuedAt: Math.floor(Date.now() / 1000),
expiresAt: Math.floor(Date.now() / 1000) + this.ttlSeconds,
};
const payload = Buffer.from(JSON.stringify(identity)).toString('base64');
const signature = this.sign(payload);
return `${payload}.${signature}`;
}
verify(token: string, expectedService: string): ServiceIdentity | null {
const [payload, signature] = token.split('.');
if (!payload || !signature) {
return null;
}
// Verify signature
const expectedSignature = this.sign(payload);
if (signature !== expectedSignature) {
return null;
}
// Decode and validate
try {
const identity: ServiceIdentity = JSON.parse(
Buffer.from(payload, 'base64').toString()
);
// Check expiration
if (identity.expiresAt < Math.floor(Date.now() / 1000)) {
return null;
}
// Check service name
if (identity.service !== expectedService) {
return null;
}
return identity;
} catch {
return null;
}
}
private sign(payload: string): string {
return crypto
.createHmac('sha256', this.secretKey)
.update(payload)
.digest('hex');
}
}

Add middleware to verify service identity:

service-auth-middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ServiceIdentityToken } from './service-identity';
declare global {
namespace Express {
interface Request {
callerIdentity?: {
service: string;
instance: string;
};
}
}
}
export function createServiceAuthMiddleware(
identityToken: ServiceIdentityToken,
allowedServices: string[]
) {
return (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers['x-service-auth'];
if (!authHeader || typeof authHeader !== 'string') {
res.status(401).json({
error: 'Missing service authentication',
code: 'MISSING_SERVICE_AUTH',
});
return;
}
// Try each allowed service
let verified = false;
for (const serviceName of allowedServices) {
const identity = identityToken.verify(authHeader, serviceName);
if (identity) {
req.callerIdentity = {
service: identity.service,
instance: identity.instance,
};
verified = true;
break;
}
}
if (!verified) {
res.status(403).json({
error: 'Invalid service credentials',
code: 'INVALID_SERVICE_AUTH',
});
return;
}
next();
};
}

Use it in your service:

payment-service.ts
import express from 'express';
import { ServiceIdentityToken } from './service-identity';
import { createServiceAuthMiddleware } from './service-auth-middleware';
const app = express();
app.use(express.json());
const serviceIdentity = new ServiceIdentityToken(
process.env.SERVICE_AUTH_SECRET!
);
// Only order-service can call this endpoint
const requireOrderService = createServiceAuthMiddleware(serviceIdentity, [
'order-service',
]);
app.post(
'/api/payments/process',
requireOrderService,
async (req, res) => {
const { orderId, amount } = req.body;
// Log who called us
console.log(
`Payment requested by ${req.callerIdentity?.service}`,
{ instance: req.callerIdentity?.instance }
);
// Process payment...
res.json({ success: true, orderId });
}
);
app.listen(3000, () => {
console.log('Payment service running on port 3000');
});

Audit Trails Matter

A Reddit comment highlighted another issue:

“Authenticating everywhere makes audit trails easier. CorrelationIds can tell you why traffic on service A/v3 is suddenly ten times as much traffic as usual, but it can’t tell you why a bunch of customer data got deleted. You need to figure out who to understand why.”

Without service-level auth, you lose accountability. Implement audit logging:

audit-logger.ts
interface AuditEvent {
timestamp: string;
callerService: string;
callerInstance: string;
targetService: string;
action: string;
resource: string;
result: 'success' | 'denied' | 'error';
metadata?: Record<string, unknown>;
}
export class AuditLogger {
private readonly serviceName: string;
constructor(serviceName: string) {
this.serviceName = serviceName;
}
log(
req: Request,
action: string,
resource: string,
result: 'success' | 'denied' | 'error',
metadata?: Record<string, unknown>
): void {
const event: AuditEvent = {
timestamp: new Date().toISOString(),
callerService: req.callerIdentity?.service || 'unknown',
callerInstance: req.callerIdentity?.instance || 'unknown',
targetService: this.serviceName,
action,
resource,
result,
metadata,
};
// Send to your logging infrastructure
console.log(JSON.stringify({
level: 'AUDIT',
message: 'Service operation',
...event,
}));
}
}
// Middleware for automatic audit logging
export function auditMiddleware(serviceName: string) {
const logger = new AuditLogger(serviceName);
return (req: Request, res: Response, next: NextFunction): void => {
const startTime = Date.now();
res.on('finish', () => {
const result = res.statusCode < 400 ? 'success' : 'error';
logger.log(
req,
req.method,
req.path,
result,
{
statusCode: res.statusCode,
durationMs: Date.now() - startTime,
}
);
});
next();
};
}

The Balanced Approach

I’m not advocating for authenticating user credentials at every service. That creates coupling and complexity. The Reddit response clarified this:

“It’s not about disabling auth. It’s about not authing the user everywhere and coupling every service to user permissions. There’s still service-to-service authentication and authorisation.”

The right approach separates concerns:

  1. Gateway: Validates user identity, creates session context
  2. Internal Services: Verify service identity, not user identity
  3. Audit Trail: Logs which service made which request

text ┌─────────────────────────────────────────────────────────────────┐ │ External Traffic │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────┐ │ API Gateway │ │ - User Auth │ │ - Create Session │ │ - Add Service Token │ └───────────────────────┘ │ ┌───────────┴───────────┐ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Service A │ │ Service B │ │ - Verify │ │ - Verify │ │ Service │ │ Service │ │ Token │ │ Token │ └─────────────┘ └─────────────┘ │ │ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Service C │ │ Payments │ │ (mTLS) │ │ (mTLS) │ └─────────────┘ └─────────────┘

Zero Trust Internal Network text

Practical Implementation Steps

  1. Start with NetworkPolicies: Even without service identity, limit which pods can talk to which services.

  2. Add Service Identity Tokens: Generate short-lived tokens that services use to identify themselves.

  3. Implement mTLS: Use a service mesh like Istio or Linkerd for automatic certificate management.

  4. Log Everything: Every service-to-service call should have an audit trail.

  5. Rotate Secrets: Use tools like SPIRE to automatically rotate service credentials.

When Gateway-Only Is Acceptable

Gateway-only authentication isn’t always wrong. It works for:

  • Development environments with no production data
  • Internal tools with limited blast radius
  • Early-stage startups prioritizing velocity over security

But understand the trade-off. You’re betting that your internal network will never be compromised. History shows that’s a risky bet.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments