StackPractices
intermediate By Mathias Paulenko

Gatekeeper Pattern: Centralized Edge Security

Centralize authentication, rate limiting, and input sanitization at the system edge. Gatekeeper Pattern with Python, Java, and JavaScript examples.

Overview

Every request that reaches your services burns compute, and every forgotten /debug route or unvalidated query parameter is an incident waiting for a scanner to find it. The Gatekeeper Pattern puts a dedicated validation and security boundary at the edge of the system: a single checkpoint that inspects, sanitizes, authenticates, and authorizes incoming traffic before any of it touches internal services.

Instead of duplicating security checks in every service, the gatekeeper centralizes the cross-cutting work — token validation, rate limiting, input sanitization, TLS termination, DDoS filtering — in one place. Anything that fails inspection gets rejected with a logged reason and never consumes a backend cycle. The result is a smaller attack surface per service and one place where security policy actually lives.

In production this boundary is usually an API gateway (Kong, AWS API Gateway), a reverse proxy with a WAF (Nginx + ModSecurity, Cloudflare), a service-mesh ingress (Istio Gateway), or — at the smallest scale — plain application middleware, which is what the examples below build.

When to Use

Use the Gatekeeper Pattern when:

  • Several backend services share the same authentication, rate-limiting, and input-validation requirements
  • Internal services should never be directly reachable from the internet
  • Compliance or audit requirements demand a single point where all external requests are logged
  • You need to enforce security policy in one place instead of trusting every team to remember it per service

If you haven’t mapped which entry points your system actually exposes, do that first: a threat modeling pass will tell you what the gatekeeper needs to guard.

When NOT to Use

  • A single-service app where edge validation adds a hop but no security benefit
  • Ultra-low-latency paths where an extra network inspection layer is unacceptable
  • Validation that depends on business state (does this order belong to this customer?) belongs in the service, not the edge
  • When the gatekeeper can’t be made highly available; a single gatekeeper instance is a single point of failure

Solution

Python (FastAPI middleware)

import os
import re
import time

import jwt
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

app = FastAPI()

# Fail fast at startup if the secret is missing — never default to a guessable value.
JWT_SECRET = os.environ["JWT_SECRET"]
JWT_ALGORITHM = "HS256"


class GatekeeperMiddleware(BaseHTTPMiddleware):
    """Validates, sanitizes, and authenticates requests at the edge."""

    BLOCKED_PATHS = {"/admin", "/internal", "/debug"}
    PUBLIC_PREFIXES = ("/api/public", "/health")
    SQL_INJECTION_PATTERNS = [
        r"(\b(union|select|insert|update|delete|drop)\b)",
        r"(--|;|/\*|\*/)",
        r"(\b(or|and)\b\s+\d+\s*=\s*\d+)",
    ]

    RATE_LIMIT = 100  # requests per window
    RATE_WINDOW = 60  # seconds

    def __init__(self, app):
        super().__init__(app)
        self.request_counts: dict[str, list[float]] = {}

    async def dispatch(self, request: Request, call_next):
        client_ip = request.client.host if request.client else "unknown"

        # 1. Path validation
        if self._is_blocked_path(request.url.path):
            return JSONResponse(
                status_code=403,
                content={"error": "Access denied", "code": "BLOCKED_PATH"},
            )

        # 2. Rate limiting
        if self._is_rate_limited(client_ip):
            return JSONResponse(
                status_code=429,
                content={"error": "Rate limit exceeded", "code": "RATE_LIMITED"},
            )

        # 3. Input sanitization
        if self._contains_injection(request):
            return JSONResponse(
                status_code=400,
                content={"error": "Malformed request", "code": "INJECTION_DETECTED"},
            )

        # 4. Authentication — only on protected routes; public routes skip it.
        if not request.url.path.startswith(self.PUBLIC_PREFIXES):
            auth_result = self._authenticate(request)
            if not auth_result["valid"]:
                return JSONResponse(
                    status_code=401,
                    content={"error": auth_result["error"], "code": "AUTH_FAILED"},
                )
            request.state.user = auth_result["user"]

        request.state.request_id = f"req-{int(time.time() * 1000)}"
        response = await call_next(request)

        # 5. Security headers on the way out
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["X-Request-ID"] = request.state.request_id
        return response

    def _is_blocked_path(self, path: str) -> bool:
        return any(path.startswith(b) for b in self.BLOCKED_PATHS)

    def _is_rate_limited(self, client_ip: str) -> bool:
        now = time.time()
        window_start = now - self.RATE_WINDOW
        timestamps = [t for t in self.request_counts.get(client_ip, []) if t > window_start]
        if len(timestamps) >= self.RATE_LIMIT:
            self.request_counts[client_ip] = timestamps
            return True
        timestamps.append(now)
        self.request_counts[client_ip] = timestamps
        return False

    def _contains_injection(self, request: Request) -> bool:
        target = f"{request.url.path}?{request.url.query}"
        return any(
            re.search(p, target, re.IGNORECASE) for p in self.SQL_INJECTION_PATTERNS
        )

    def _authenticate(self, request: Request) -> dict:
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return {"valid": False, "error": "Missing or invalid authorization header"}
        try:
            payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=[JWT_ALGORITHM])
            return {"valid": True, "user": payload}
        except jwt.ExpiredSignatureError:
            return {"valid": False, "error": "Token expired"}
        except jwt.InvalidTokenError:
            return {"valid": False, "error": "Invalid token"}


app.add_middleware(GatekeeperMiddleware)


@app.get("/api/protected/users/me")
async def get_current_user(request: Request):
    user = request.state.user
    return {"user_id": user["sub"], "email": user["email"]}


@app.get("/api/public/products")
async def list_products():
    return {"products": [{"id": 1, "name": "Widget"}]}

Java (Spring Cloud Gateway filter)

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.net.InetSocketAddress;
import java.util.List;
import java.util.UUID;

@Component
class GatekeeperFilter extends AbstractGatewayFilterFactory<GatekeeperFilter.Config> {

    private static final List<String> BLOCKED_PREFIXES = List.of("/admin", "/internal", "/debug");
    private static final String PUBLIC_PREFIX = "/api/public";

    private final JwtValidator jwtValidator;      // JWT secret comes from config, not code
    private final RateLimiter rateLimiter;

    public GatekeeperFilter(JwtValidator jwtValidator, RateLimiter rateLimiter) {
        super(Config.class);
        this.jwtValidator = jwtValidator;
        this.rateLimiter = rateLimiter;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String path = request.getPath().value();

            // 1. Block internal paths
            if (isBlockedPath(path)) {
                return reject(exchange, HttpStatus.FORBIDDEN);
            }

            // 2. Rate limiting — getRemoteAddress() can be null behind some proxies
            InetSocketAddress remote = request.getRemoteAddress();
            String clientIp = remote != null ? remote.getAddress().getHostAddress() : "unknown";
            if (!rateLimiter.allowRequest(clientIp)) {
                return reject(exchange, HttpStatus.TOO_MANY_REQUESTS);
            }

            // 3. Authentication — only on protected routes
            if (!path.startsWith(PUBLIC_PREFIX)) {
                String authHeader = request.getHeaders().getFirst("Authorization");
                if (authHeader == null || !authHeader.startsWith("Bearer ")) {
                    return reject(exchange, HttpStatus.UNAUTHORIZED);
                }
                if (!jwtValidator.isValid(authHeader.substring(7))) {
                    return reject(exchange, HttpStatus.UNAUTHORIZED);
                }
            }

            // 4. Tag the request and forward
            ServerHttpRequest mutated = request.mutate()
                .header("X-Request-ID", UUID.randomUUID().toString())
                .header("X-Authenticated", "true")
                .build();
            return chain.filter(exchange.mutate().request(mutated).build());
        };
    }

    private boolean isBlockedPath(String path) {
        return BLOCKED_PREFIXES.stream().anyMatch(path::startsWith);
    }

    private Mono<Void> reject(ServerWebExchange exchange, HttpStatus status) {
        exchange.getResponse().setStatusCode(status);
        return exchange.getResponse().setComplete();
    }

    public static class Config {
        // Configuration properties
    }
}

JavaScript (Express middleware stack)

const express = require('express');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const jwt = require('jsonwebtoken');

const app = express();

// Fail fast at startup if the secret is missing — never ship a fallback secret.
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('JWT_SECRET environment variable is required');

// 1. Security headers (Helmet)
app.use(helmet());

// 2. Rate limiting
const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: { error: 'Rate limit exceeded', code: 'RATE_LIMITED' },
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api/', limiter);

// 3. Path blocking
const blockedPaths = ['/admin', '/internal', '/debug', '/.env', '/wp-admin'];
app.use((req, res, next) => {
  if (blockedPaths.some((p) => req.path.startsWith(p))) {
    return res.status(403).json({ error: 'Access denied', code: 'BLOCKED_PATH' });
  }
  next();
});

// 4. Input sanitization — checks the path and query string for injection patterns
const sqlInjectionPattern = /(\b(union|select|insert|update|delete|drop)\b|--|;)/i;
app.use((req, res, next) => {
  const target = `${req.path}?${new URLSearchParams(req.query).toString()}`;
  if (sqlInjectionPattern.test(target)) {
    return res.status(400).json({ error: 'Malformed request', code: 'INJECTION_DETECTED' });
  }
  next();
});

// 5. JWT authentication — scoped to protected routes only
app.use('/api/protected', (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required', code: 'AUTH_FAILED' });
  }
  try {
    req.user = jwt.verify(authHeader.slice(7), JWT_SECRET);
    req.requestId = `req-${Date.now()}`;
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token', code: 'AUTH_FAILED' });
  }
});

// Backend routes — protected and public are now explicit
app.get('/api/protected/users/me', (req, res) => {
  res.json({ userId: req.user.sub, requestId: req.requestId });
});

app.get('/api/public/products', (req, res) => {
  res.json({ products: [{ id: 1, name: 'Widget' }] });
});

app.listen(3000, () => console.log('Gatekeeper listening on port 3000'));

Notice that all three examples draw the same line: /api/public/* passes inspection but skips authentication, while everything else needs a valid JWT. Get that boundary wrong and the failure is silent — middleware that authenticates every route, including the public product list, ships fine and then someone notices the public endpoint returns 401.

How It Works

flowchart diagram: Client

Each layer of the gatekeeper maps to a rejection code: blocked paths get a 403, rate-limited clients a 429, malformed input a 400, and missing or invalid tokens a 401. Only a request that clears every layer reaches the backend, and the rejection is logged so attack patterns show up in monitoring before they show up in an incident report.

The edge validates format, not semantics. The gatekeeper can check that a token is signed and unexpired, that a path isn’t on a blocklist, that a query string doesn’t look like SQL injection. It can’t check whether this user is allowed to see that order — that needs business state, and business state lives in the service. If you find the gatekeeper loading entities from the database, the boundary has leaked.

Fail closed or fail audited. When the JWT signing key fetch fails or the rate limiter store is down, the gatekeeper has two choices: reject everything (fail closed) or let traffic through uninspected (fail open). Fail open turns an infrastructure blip into a security incident. If availability makes fail-closed unacceptable, degrade to a minimal ruleset — path blocking plus logging — and alert loudly.

The chokepoint cuts both ways. One policy enforcement point means one place to update rules — and one place that can take the whole system down. Run at least two replicas behind a load balancer, keep the gatekeeper stateless so replicas are interchangeable (rate-limit state goes in Redis, not process memory), and load-test the layer itself: it adds latency to every request, not just the bad ones.

A gatekeeper isn’t a substitute for zero trust. Internal services should still verify the tokens the edge already checked — a request that bypasses the gateway (a misconfigured ingress, a port left open, an internal caller) shouldn’t get a free pass. The gatekeeper shrinks the attack surface; service-level auth keeps it shrunk.

Variants

VariantTechnologyUse Case
API GatewayKong, AWS API Gateway, Azure APIMFull edge stack with plugin policies, key management, and routing
Reverse proxy + WAFNginx + ModSecurity, CloudflareNetwork-level rulesets (OWASP CRS) without touching application code
Service mesh ingressIstio Gateway, LinkerdKubernetes-native edge with mTLS between services
CDN edge computeCloudflare Workers, Lambda@EdgeValidation executed geographically close to the client
Application middlewareExpress, FastAPI, SpringCode-level control with no extra infrastructure — the examples above

For a walkthrough of building a managed API gateway with routing, throttling, and key management, see the API gateway recipe.

GatekeeperIntercepting FilterFront Controller
Where it livesEdge, in front of the systemInside the app, around handlersEntry point of the app
Main jobReject bad trafficPre/postprocess requestsRoute requests to handlers
Typical techGateway, WAF, proxyMiddleware, servlet filtersDispatcher servlet, router
Drops requests?Yes — that’s the pointRarely — usually transformsNo — dispatches

These compose rather than compete: a gatekeeper sits at the edge, a front controller routes what’s left, and intercepting filters run inside the app around the handler.

What Works

  • Fail closed. A request the gatekeeper can’t validate is a request the backend never sees.
  • Log every rejection with its reason. Rejection patterns are your earliest signal of scanning and attack attempts.
  • Keep the gatekeeper stateless. Rate-limit counters and token caches belong in Redis or the gateway’s own store, so replicas stay interchangeable.
  • Version the ruleset like code. WAF rules and blocklists change; review them in Git and roll out through CI/CD like anything else that can break production.
  • Test the bypass. Regularly verify that internal services really are unreachable if the gatekeeper is skipped; that’s the assumption everything else rests on.

Companion code: The gatekeeper-pattern companion repo has runnable versions of the three middleware stacks with tests covering each rejection path.

Common Mistakes

  • Trusting traffic because it’s “internal”. A request that skipped the gatekeeper — a misconfigured ingress, an exposed port — still gets full service access unless the service re-validates. Zero trust means the edge is one layer, not the only layer.
  • Business logic at the edge. The gatekeeper doesn’t know whether order #512 belongs to this customer. Edge checks stay generic: signatures, formats, rates, paths.
  • Fail-open on dependency errors. If the rate-limit store or JWKS endpoint is down, silently forwarding traffic uninspected converts an outage into a breach window.
  • Secrets in the ruleset. JWT keys, API keys, and certs get injected from a secrets manager or environment — the examples above read JWT_SECRET at startup precisely because a hardcoded key ends up in the repo.
  • WAF rules tuned by guesswork. Aggressive regexes block legitimate users (select appears in plenty of harmless queries). Deploy new rules in log-only mode first, then enforce once false positives are known.

Real-World Examples

Cloudflare

Cloudflare terminates TLS, filters DDoS floods, applies WAF rules, and screens bots at its edge network before traffic reaches the origin — for millions of sites, it is the gatekeeper, and the origin server sits behind it assuming traffic arrives pre-inspected.

AWS API Gateway

API Gateway throttles per API key, validates JWTs against Cognito or a Lambda authorizer, transforms requests, and logs everything to CloudWatch before invoking the Lambda or EC2 backend. The service behind it can stay minimal because the edge already did the checking.

Azure Front Door + WAF

Microsoft documents this pattern under the name Gatekeeper in the Azure Architecture Center: Azure Front Door or Application Gateway with the Web Application Firewall handles TLS termination, OWASP rulesets, and rate limiting in front of AKS or App Service backends.

Further Reading

Frequently Asked Questions

What is the difference between Gatekeeper and API Gateway?

An API gateway is a superset: it routes requests, translates protocols, aggregates responses, and often manages API keys and developer portals. A gatekeeper is the security slice of that job — validate, sanitize, authenticate, reject. Every API gateway can act as a gatekeeper; a gatekeeper doesn't need to route anything.

Should the gatekeeper authenticate or just pass tokens through?

Validate at the edge — signature, expiry, issuer — so forged or expired tokens die cheap. Keep authorization ("can user X touch resource Y") either coarse at the edge (scopes, roles) or fine-grained in the service. The split that works: the gatekeeper proves who the caller is; the service decides what they're allowed to do.

How does Gatekeeper relate to a service mesh?

They cover different directions. The gatekeeper handles north-south traffic: external clients hitting the system. A mesh like Istio handles east-west traffic: service-to-service calls with mTLS and per-service policies. On Kubernetes, the ingress gateway is the gatekeeper and the mesh continues the same enforcement internally.

What happens when the gatekeeper becomes the bottleneck?

Scale it like any stateless tier: replicas behind a load balancer, shared state in Redis, and CPU-heavy work (bot scoring, payload inspection) offloaded or sampled. If the gatekeeper still adds unacceptable latency, that usually means it's doing work that belongs in services — the fail-closed audit question is which checks can move downstream without opening holes.

Should TLS terminate at the gatekeeper?

Usually yes. The edge is where certificates are managed and inspection happens. If compliance requires encryption end to end, re-encrypt from gatekeeper to services (which is what service mesh mTLS gives you for free). The mistake is terminating TLS and then sending plaintext inside a network that isn't actually trusted.