An API gateway at the edge does two jobs exceptionally well: it keeps unauthenticated or abusive traffic away from your origin, and it makes cheap decisions close to the user. Everything else — protocol translation, request transformation, business logic — adds weight to every single request. This build keeps the gateway to one deployable unit that validates JWTs, enforces per-caller rate limits, and forwards only traffic that deserves an origin response.
Decide what the gateway owns before you write code
A gateway that tries to do everything becomes a distributed monolith with worse debugging. For this project, limit it to four responsibilities:
- Verify the caller's identity from a signed token.
- Apply a quota tied to that identity.
- Route the request to the correct origin, with timeouts and size limits.
- Emit one structured log line per request.
Notice what's missing: no database calls on the request path, no payload parsing, no service discovery beyond a static map. The gateway's job is to say yes or no quickly, then get out of the way.
Choose a runtime that boots in milliseconds
Two families work well. Managed edge runtimes (Cloudflare Workers, Deno Deploy, Fastly Compute) give you global distribution without a fleet to operate. A small Go or Rust binary on a few anycast-fronted VMs gives you complete control and predictable behaviour under load. A containerised Node service behind a CDN is the least exciting option and usually the slowest to cold-start.
The deciding question is where your shared state lives. Edge runtimes are stateless by design, so any counter that must be correct across regions needs an external store: Durable Objects, a hosted Redis, or a regional database. If your rate limits only need to be roughly accurate, an eventually consistent key-value store is fine. If a client must never exceed 1,000 requests per minute across the planet, you need a strongly consistent counter — and that constraint should drive your runtime choice, not the other way round.
Step 1: Validate the JWT before anything else
Authentication runs first. There is no point spending a Redis round trip on a request whose signature you haven't checked.
Fetch your identity provider's JWKS document and cache it, keyed by the token's kid header. Cache for fifteen to sixty minutes, but handle rotation by refetching once when you see an unknown kid — and only once. A loop that refetches JWKS on every unknown key is a denial-of-service vector you built yourself.
Then validate, in this order: the algorithm is one you explicitly allow; the signature verifies against the key; iss matches your issuer exactly; aud contains your API's audience; exp is in the future and nbf is in the past, both with thirty to sixty seconds of leeway for clock skew. Reject anything with alg: none, and never accept a symmetric algorithm when you published an asymmetric key. That confusion attack is old and still works against hand-rolled verifiers.
Use a maintained library here. Cryptography is the one part of a gateway where "I wrote it myself" is a warning, not a virtue.
Once verified, extract the subject and scopes and pass them upstream as internal headers such as x-auth-sub and x-auth-scopes. Strip any inbound copy of those headers first, or a client can forge identity by setting them directly.
Step 2: Rate limit on identity, not IP
IP-based limiting breaks the moment a corporate NAT or a mobile carrier sits in front of thousands of legitimate users. Use the verified sub claim as your primary key, scoped per route or per permission. Fall back to IP only for anonymous endpoints, and treat that as a coarse shield rather than a policy.
A token bucket is the right default: it allows the short bursts real clients produce while enforcing a steady average. Store buckets under keys like rl:{sub}:{route} with a TTL slightly longer than the refill window, so idle buckets expire on their own.
Keep the limiter fast and predictable
Give the shared store a hard timeout of ten to fifteen milliseconds and decide in advance what happens when it doesn't answer. For rate limiting, failing open is usually correct: let the request through, log it, alert on it. Failing closed turns a Redis blip into a full outage. For authentication the opposite applies — if you cannot verify a token, deny the request.
Return a 429 with a Retry-After header and remaining-quota headers so clients can back off intelligently instead of retrying in a tight loop. Document that contract. Clients that know the rules behave far better than clients that guess.
Step 3: Proxy to the origin without leaking trust
Forwarding is where small mistakes become security incidents. Keep the outbound request minimal:
- Strip hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, Upgrade) and any client-supplied forwarding headers you don't control.
- Generate your own X-Request-Id and propagate it to both the origin and the log line.
- Cap request body size before buffering anything, and set a read timeout on the upstream call.
- Authenticate to the origin with something clients cannot obtain: mTLS, a private network path, or a secret header the origin verifies.
- Retry only idempotent methods, once or twice at most, with a short budget and jitter.
If your origin is reachable directly from the internet, all of the above is optional decoration. Firewall it so the only path in is through the gateway.
Test the failure modes, not the happy path
Happy-path tests pass on day one and prove nothing. Add cases for an expired token, a token signed by the wrong key, an algorithm-confusion attempt, a valid token issued for a different service, an unknown kid followed by a rotation, a client clock that runs two minutes fast, a request arriving exactly at the bucket boundary, and a rate-limit store that refuses to answer at all.
Then load test with k6 or autocannon from a couple of regions and watch p95 latency rather than averages. Set a budget and defend it: a gateway that adds forty milliseconds to every request is a tax on your whole product.
Observe enough to debug it at 3 a.m.
Write one structured log line per request containing the request ID, subject, route, status, upstream latency, and the rate-limit decision. Never log the token itself, not even truncated — it is a live credential. Track four counters: total requests, 401s, 429s, and origin errors. Those four tell you whether you have an attack, a client integration bug, or an origin problem, which covers most of what you'll ever need to know.
Ship it in stages
Deploy in shadow mode first. Run the gateway alongside production traffic, log what it would have rejected, and change nothing. Fix the false positives — usually an audience mismatch or a clock issue — then route five percent of real traffic through it, then all of it.
Keep a rollback path that takes thirty seconds: flip the DNS record or the routing rule back to the origin. Rotate the shared secret between gateway and origin on a schedule. And once it is live, resist adding features. A gateway that stays boring, fast, and small is doing exactly what it was hired to do.
Photo: Pexels / Pixabay

